openclaw-tavily 0.1.0 → 0.2.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/index.ts CHANGED
@@ -1,18 +1,21 @@
1
1
  /**
2
- * openclaw-tavily — Tavily web search plugin for OpenClaw
2
+ * openclaw-tavily — Tavily web tools plugin for OpenClaw
3
3
  *
4
- * Exposes a `tavily_search` tool that calls the Tavily Search API v2.
5
- * Returns structured results (title, url, snippet, optional raw content)
6
- * plus an optional AI-generated short answer.
4
+ * Exposes five tools:
5
+ * - `tavily_search` — web search with structured results and AI answers
6
+ * - `tavily_extract` — extract clean content from URLs
7
+ * - `tavily_crawl` — crawl a website and extract page content
8
+ * - `tavily_map` — discover and list URLs from a website
9
+ * - `tavily_research` — deep agentic research returning comprehensive reports
7
10
  *
8
- * API reference: https://docs.tavily.com/documentation/api-reference/search
11
+ * API reference: https://docs.tavily.com/documentation/api-reference
9
12
  *
10
13
  * Config (openclaw.json → plugins.entries.openclaw-tavily.config):
11
14
  * apiKey - Tavily API key (or set TAVILY_API_KEY env var)
12
- * searchDepth - "basic" | "advanced" (default: "advanced")
15
+ * searchDepth - "basic" | "advanced" | "fast" | "ultra-fast" (default: "advanced")
13
16
  * maxResults - 1-20 (default: 5)
14
- * includeAnswer - boolean (default: true)
15
- * includeRawContent - boolean (default: false)
17
+ * includeAnswer - boolean | "basic" | "advanced" (default: true)
18
+ * includeRawContent - boolean | "markdown" | "text" (default: false)
16
19
  * timeoutSeconds - number (default: 30)
17
20
  * cacheTtlMinutes - number (default: 15)
18
21
  */
@@ -42,6 +45,7 @@ type TavilySearchResult = {
42
45
  content: string; // snippet
43
46
  raw_content?: string;
44
47
  score: number;
48
+ favicon?: string;
45
49
  };
46
50
 
47
51
  type TavilySearchResponse = {
@@ -62,6 +66,10 @@ type CacheEntry = {
62
66
  // ---------------------------------------------------------------------------
63
67
 
64
68
  const TAVILY_SEARCH_ENDPOINT = "https://api.tavily.com/search";
69
+ const TAVILY_EXTRACT_ENDPOINT = "https://api.tavily.com/extract";
70
+ const TAVILY_CRAWL_ENDPOINT = "https://api.tavily.com/crawl";
71
+ const TAVILY_MAP_ENDPOINT = "https://api.tavily.com/map";
72
+ const TAVILY_RESEARCH_ENDPOINT = "https://api.tavily.com/research";
65
73
  const DEFAULT_SEARCH_DEPTH = "advanced";
66
74
  const DEFAULT_MAX_RESULTS = 5;
67
75
  const MAX_RESULTS_CAP = 20;
@@ -105,9 +113,12 @@ function resolveApiKey(cfg: Record<string, unknown>): string | undefined {
105
113
  return fromConfig || fromEnv || undefined;
106
114
  }
107
115
 
108
- function resolveSearchDepth(cfg: Record<string, unknown>): "basic" | "advanced" {
116
+ type SearchDepth = "basic" | "advanced" | "fast" | "ultra-fast";
117
+
118
+ function resolveSearchDepth(cfg: Record<string, unknown>): SearchDepth {
109
119
  const v = typeof cfg.searchDepth === "string" ? cfg.searchDepth.trim().toLowerCase() : "";
110
- return v === "basic" ? "basic" : DEFAULT_SEARCH_DEPTH;
120
+ if (v === "basic" || v === "fast" || v === "ultra-fast") return v as SearchDepth;
121
+ return DEFAULT_SEARCH_DEPTH;
111
122
  }
112
123
 
113
124
  function resolveMaxResults(cfg: Record<string, unknown>): number {
@@ -115,12 +126,18 @@ function resolveMaxResults(cfg: Record<string, unknown>): number {
115
126
  return Math.max(1, Math.min(MAX_RESULTS_CAP, Math.floor(v)));
116
127
  }
117
128
 
118
- function resolveIncludeAnswer(cfg: Record<string, unknown>): boolean {
119
- return cfg.includeAnswer !== false; // default true
129
+ function resolveIncludeAnswer(cfg: Record<string, unknown>): boolean | string {
130
+ const v = cfg.includeAnswer;
131
+ if (typeof v === "string" && ["basic", "advanced"].includes(v)) return v;
132
+ if (v === false) return false;
133
+ return true; // default true
120
134
  }
121
135
 
122
- function resolveIncludeRawContent(cfg: Record<string, unknown>): boolean {
123
- return cfg.includeRawContent === true; // default false
136
+ function resolveIncludeRawContent(cfg: Record<string, unknown>): boolean | string {
137
+ const v = cfg.includeRawContent;
138
+ if (typeof v === "string" && ["markdown", "text"].includes(v)) return v;
139
+ if (v === true) return true;
140
+ return false; // default false
124
141
  }
125
142
 
126
143
  function resolveTimeout(cfg: Record<string, unknown>): number {
@@ -158,29 +175,41 @@ const TavilySearchSchema = Type.Object({
158
175
  search_depth: Type.Optional(
159
176
  Type.String({
160
177
  description:
161
- 'Search depth: "basic" (fast, cheaper) or "advanced" (thorough). Default: from config.',
178
+ 'Search depth: "ultra-fast", "fast", "basic", or "advanced" (thorough). Default: from config.',
162
179
  }),
163
180
  ),
164
181
  include_answer: Type.Optional(
165
- Type.Boolean({
166
- description: "Include an AI-generated short answer. Default: from config.",
182
+ Type.Union([Type.Boolean(), Type.String()], {
183
+ description:
184
+ 'Include an AI-generated short answer. Boolean or "basic"/"advanced". Default: from config.',
167
185
  }),
168
186
  ),
169
187
  include_raw_content: Type.Optional(
170
- Type.Boolean({
171
- description: "Include raw page content in results. Default: from config.",
188
+ Type.Union([Type.Boolean(), Type.String()], {
189
+ description:
190
+ 'Include raw page content. Boolean or "markdown"/"text". Default: from config.',
172
191
  }),
173
192
  ),
174
193
  topic: Type.Optional(
175
194
  Type.String({
176
195
  description:
177
- 'Category of search: "general" or "news". Default: "general".',
196
+ 'Category of search: "general", "news", or "finance". Default: "general".',
178
197
  }),
179
198
  ),
180
- days: Type.Optional(
181
- Type.Number({
199
+ time_range: Type.Optional(
200
+ Type.String({
182
201
  description:
183
- "Number of days back to search (only for topic=news). Default: 3.",
202
+ 'Time range filter: "day", "week", "month", or "year".',
203
+ }),
204
+ ),
205
+ start_date: Type.Optional(
206
+ Type.String({
207
+ description: "Start date for results (ISO date string, e.g. \"2024-01-01\").",
208
+ }),
209
+ ),
210
+ end_date: Type.Optional(
211
+ Type.String({
212
+ description: "End date for results (ISO date string, e.g. \"2024-12-31\").",
184
213
  }),
185
214
  ),
186
215
  include_domains: Type.Optional(
@@ -193,6 +222,241 @@ const TavilySearchSchema = Type.Object({
193
222
  description: "Exclude results from these domains.",
194
223
  }),
195
224
  ),
225
+ chunks_per_source: Type.Optional(
226
+ Type.Number({
227
+ description: "Number of content chunks per source (1-3, only for advanced depth).",
228
+ minimum: 1,
229
+ maximum: 3,
230
+ }),
231
+ ),
232
+ include_images: Type.Optional(
233
+ Type.Boolean({
234
+ description: "Include images in results. Default: false.",
235
+ }),
236
+ ),
237
+ include_image_descriptions: Type.Optional(
238
+ Type.Boolean({
239
+ description: "Include descriptions for images. Default: false.",
240
+ }),
241
+ ),
242
+ include_favicon: Type.Optional(
243
+ Type.Boolean({
244
+ description: "Include favicon URLs for each result. Default: false.",
245
+ }),
246
+ ),
247
+ country: Type.Optional(
248
+ Type.String({
249
+ description: "Country code for geo-boosted results (only for general topic), e.g. \"us\", \"gb\".",
250
+ }),
251
+ ),
252
+ auto_parameters: Type.Optional(
253
+ Type.Boolean({
254
+ description: "Let Tavily automatically set optimal search parameters. Default: false.",
255
+ }),
256
+ ),
257
+ });
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Extract schema
261
+ // ---------------------------------------------------------------------------
262
+
263
+ const TavilyExtractSchema = Type.Object({
264
+ urls: Type.Array(Type.String(), {
265
+ description: "URLs to extract content from.",
266
+ }),
267
+ query: Type.Optional(
268
+ Type.String({
269
+ description: "Optional query to rerank extracted chunks for relevance.",
270
+ }),
271
+ ),
272
+ chunks_per_source: Type.Optional(
273
+ Type.Number({
274
+ description: "Max content snippets per source.",
275
+ }),
276
+ ),
277
+ format: Type.Optional(
278
+ Type.String({
279
+ description: 'Output format: "markdown" or "text". Default: "markdown".',
280
+ }),
281
+ ),
282
+ include_favicon: Type.Optional(
283
+ Type.Boolean({
284
+ description: "Include favicon URLs. Default: false.",
285
+ }),
286
+ ),
287
+ timeout: Type.Optional(
288
+ Type.Number({
289
+ description: "Max seconds before timeout.",
290
+ }),
291
+ ),
292
+ });
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Crawl schema
296
+ // ---------------------------------------------------------------------------
297
+
298
+ const TavilyCrawlSchema = Type.Object({
299
+ url: Type.String({ description: "Root URL to start crawling from." }),
300
+ instructions: Type.Optional(
301
+ Type.String({
302
+ description: "Natural language guidance for the crawl.",
303
+ }),
304
+ ),
305
+ max_depth: Type.Optional(
306
+ Type.Number({
307
+ description: "Crawl depth (1-5).",
308
+ minimum: 1,
309
+ maximum: 5,
310
+ }),
311
+ ),
312
+ max_breadth: Type.Optional(
313
+ Type.Number({
314
+ description: "Max links to follow per level (1-500).",
315
+ minimum: 1,
316
+ maximum: 500,
317
+ }),
318
+ ),
319
+ limit: Type.Optional(
320
+ Type.Number({
321
+ description: "Total URL cap.",
322
+ }),
323
+ ),
324
+ select_paths: Type.Optional(
325
+ Type.Array(Type.String(), {
326
+ description: "Regex include filters for URL paths.",
327
+ }),
328
+ ),
329
+ select_domains: Type.Optional(
330
+ Type.Array(Type.String(), {
331
+ description: "Regex include filters for domains.",
332
+ }),
333
+ ),
334
+ exclude_paths: Type.Optional(
335
+ Type.Array(Type.String(), {
336
+ description: "Regex exclude filters for URL paths.",
337
+ }),
338
+ ),
339
+ exclude_domains: Type.Optional(
340
+ Type.Array(Type.String(), {
341
+ description: "Regex exclude filters for domains.",
342
+ }),
343
+ ),
344
+ allow_external: Type.Optional(
345
+ Type.Boolean({
346
+ description: "Follow external links. Default: false.",
347
+ }),
348
+ ),
349
+ include_images: Type.Optional(
350
+ Type.Boolean({
351
+ description: "Include images in results. Default: false.",
352
+ }),
353
+ ),
354
+ extract_depth: Type.Optional(
355
+ Type.String({
356
+ description: 'Extraction depth: "basic" or "advanced".',
357
+ }),
358
+ ),
359
+ format: Type.Optional(
360
+ Type.String({
361
+ description: 'Output format: "markdown" or "text".',
362
+ }),
363
+ ),
364
+ include_favicon: Type.Optional(
365
+ Type.Boolean({
366
+ description: "Include favicons. Default: false.",
367
+ }),
368
+ ),
369
+ timeout: Type.Optional(
370
+ Type.Number({
371
+ description: "Timeout in seconds (10-150).",
372
+ minimum: 10,
373
+ maximum: 150,
374
+ }),
375
+ ),
376
+ });
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Map schema
380
+ // ---------------------------------------------------------------------------
381
+
382
+ const TavilyMapSchema = Type.Object({
383
+ url: Type.String({ description: "URL to map." }),
384
+ instructions: Type.Optional(
385
+ Type.String({
386
+ description: "Natural language guidance for the map.",
387
+ }),
388
+ ),
389
+ max_depth: Type.Optional(
390
+ Type.Number({
391
+ description: "Crawl depth (1-5).",
392
+ minimum: 1,
393
+ maximum: 5,
394
+ }),
395
+ ),
396
+ max_breadth: Type.Optional(
397
+ Type.Number({
398
+ description: "Max links to follow per level.",
399
+ }),
400
+ ),
401
+ limit: Type.Optional(
402
+ Type.Number({
403
+ description: "Total URL cap.",
404
+ }),
405
+ ),
406
+ select_paths: Type.Optional(
407
+ Type.Array(Type.String(), {
408
+ description: "Include filters for URL paths.",
409
+ }),
410
+ ),
411
+ select_domains: Type.Optional(
412
+ Type.Array(Type.String(), {
413
+ description: "Include filters for domains.",
414
+ }),
415
+ ),
416
+ exclude_paths: Type.Optional(
417
+ Type.Array(Type.String(), {
418
+ description: "Exclude filters for URL paths.",
419
+ }),
420
+ ),
421
+ exclude_domains: Type.Optional(
422
+ Type.Array(Type.String(), {
423
+ description: "Exclude filters for domains.",
424
+ }),
425
+ ),
426
+ allow_external: Type.Optional(
427
+ Type.Boolean({
428
+ description: "Follow external links. Default: false.",
429
+ }),
430
+ ),
431
+ categories: Type.Optional(
432
+ Type.String({
433
+ description: "Category filters.",
434
+ }),
435
+ ),
436
+ });
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Research schema
440
+ // ---------------------------------------------------------------------------
441
+
442
+ const TavilyResearchSchema = Type.Object({
443
+ input: Type.String({ description: "Research question or topic." }),
444
+ model: Type.Optional(
445
+ Type.String({
446
+ description: 'Research model: "mini", "pro", or "auto". Default: "auto".',
447
+ }),
448
+ ),
449
+ output_schema: Type.Optional(
450
+ Type.Object({}, {
451
+ description: "JSON schema for structured output.",
452
+ additionalProperties: true,
453
+ }),
454
+ ),
455
+ citation_format: Type.Optional(
456
+ Type.String({
457
+ description: 'Citation format: "numbered", "mla", "apa", or "chicago".',
458
+ }),
459
+ ),
196
460
  });
197
461
 
198
462
  // ---------------------------------------------------------------------------
@@ -203,7 +467,7 @@ const tavilyPlugin = {
203
467
  id: "openclaw-tavily",
204
468
  name: "Tavily Search",
205
469
  description:
206
- "Web search via Tavily API. Provides structured results with optional AI answers.",
470
+ "Web search, extraction, crawling, mapping, and research via Tavily API. Provides tavily_search, tavily_extract, tavily_crawl, tavily_map, and tavily_research tools.",
207
471
  kind: "tools" as const,
208
472
 
209
473
  register(api: PluginApi) {
@@ -270,31 +534,44 @@ const tavilyPlugin = {
270
534
 
271
535
  const searchDepth =
272
536
  typeof params.search_depth === "string" &&
273
- ["basic", "advanced"].includes(params.search_depth)
274
- ? (params.search_depth as "basic" | "advanced")
537
+ ["basic", "advanced", "fast", "ultra-fast"].includes(params.search_depth)
538
+ ? (params.search_depth as SearchDepth)
275
539
  : defaultSearchDepth;
276
540
 
277
- const includeAnswer =
278
- typeof params.include_answer === "boolean"
541
+ const includeAnswer: boolean | string =
542
+ typeof params.include_answer === "string" &&
543
+ ["basic", "advanced"].includes(params.include_answer)
279
544
  ? params.include_answer
280
- : defaultIncludeAnswer;
545
+ : typeof params.include_answer === "boolean"
546
+ ? params.include_answer
547
+ : defaultIncludeAnswer;
281
548
 
282
- const includeRawContent =
283
- typeof params.include_raw_content === "boolean"
549
+ const includeRawContent: boolean | string =
550
+ typeof params.include_raw_content === "string" &&
551
+ ["markdown", "text"].includes(params.include_raw_content)
284
552
  ? params.include_raw_content
285
- : defaultIncludeRawContent;
553
+ : typeof params.include_raw_content === "boolean"
554
+ ? params.include_raw_content
555
+ : defaultIncludeRawContent;
286
556
 
287
557
  const topic =
288
558
  typeof params.topic === "string" &&
289
- ["general", "news"].includes(params.topic)
559
+ ["general", "news", "finance"].includes(params.topic)
290
560
  ? params.topic
291
561
  : "general";
292
562
 
293
- const days =
294
- topic === "news" && typeof params.days === "number" && params.days > 0
295
- ? Math.floor(params.days)
563
+ const timeRange =
564
+ typeof params.time_range === "string" &&
565
+ ["day", "week", "month", "year"].includes(params.time_range)
566
+ ? params.time_range
296
567
  : undefined;
297
568
 
569
+ const startDate =
570
+ typeof params.start_date === "string" ? params.start_date.trim() : undefined;
571
+
572
+ const endDate =
573
+ typeof params.end_date === "string" ? params.end_date.trim() : undefined;
574
+
298
575
  const includeDomains = Array.isArray(params.include_domains)
299
576
  ? (params.include_domains as string[]).filter(
300
577
  (d) => typeof d === "string" && d.trim(),
@@ -307,6 +584,31 @@ const tavilyPlugin = {
307
584
  )
308
585
  : undefined;
309
586
 
587
+ const chunksPerSource =
588
+ typeof params.chunks_per_source === "number" &&
589
+ searchDepth === "advanced"
590
+ ? Math.max(1, Math.min(3, Math.floor(params.chunks_per_source)))
591
+ : undefined;
592
+
593
+ const includeImages =
594
+ typeof params.include_images === "boolean" ? params.include_images : undefined;
595
+
596
+ const includeImageDescriptions =
597
+ typeof params.include_image_descriptions === "boolean"
598
+ ? params.include_image_descriptions
599
+ : undefined;
600
+
601
+ const includeFavicon =
602
+ typeof params.include_favicon === "boolean" ? params.include_favicon : undefined;
603
+
604
+ const country =
605
+ typeof params.country === "string" && topic === "general"
606
+ ? params.country.trim()
607
+ : undefined;
608
+
609
+ const autoParameters =
610
+ typeof params.auto_parameters === "boolean" ? params.auto_parameters : undefined;
611
+
310
612
  // --- cache ---
311
613
  const cacheKey = [
312
614
  "tavily",
@@ -316,9 +618,17 @@ const tavilyPlugin = {
316
618
  includeAnswer,
317
619
  includeRawContent,
318
620
  topic,
319
- days ?? "default",
621
+ timeRange ?? "",
622
+ startDate ?? "",
623
+ endDate ?? "",
320
624
  (includeDomains ?? []).join(","),
321
625
  (excludeDomains ?? []).join(","),
626
+ chunksPerSource ?? "",
627
+ includeImages ?? "",
628
+ includeImageDescriptions ?? "",
629
+ includeFavicon ?? "",
630
+ country ?? "",
631
+ autoParameters ?? "",
322
632
  ]
323
633
  .join(":")
324
634
  .toLowerCase();
@@ -339,18 +649,26 @@ const tavilyPlugin = {
339
649
  // --- build Tavily API request body ---
340
650
  const body: Record<string, unknown> = {
341
651
  query,
342
- api_key: apiKey,
343
652
  search_depth: searchDepth,
344
653
  max_results: count,
345
654
  include_answer: includeAnswer,
346
655
  include_raw_content: includeRawContent,
347
656
  topic,
348
657
  };
349
- if (days !== undefined) body.days = days;
658
+ if (timeRange !== undefined) body.time_range = timeRange;
659
+ if (startDate) body.start_date = startDate;
660
+ if (endDate) body.end_date = endDate;
350
661
  if (includeDomains && includeDomains.length > 0)
351
662
  body.include_domains = includeDomains;
352
663
  if (excludeDomains && excludeDomains.length > 0)
353
664
  body.exclude_domains = excludeDomains;
665
+ if (chunksPerSource !== undefined) body.chunks_per_source = chunksPerSource;
666
+ if (includeImages !== undefined) body.include_images = includeImages;
667
+ if (includeImageDescriptions !== undefined)
668
+ body.include_image_descriptions = includeImageDescriptions;
669
+ if (includeFavicon !== undefined) body.include_favicon = includeFavicon;
670
+ if (country) body.country = country;
671
+ if (autoParameters !== undefined) body.auto_parameters = autoParameters;
354
672
 
355
673
  // --- call Tavily ---
356
674
  const start = Date.now();
@@ -364,7 +682,10 @@ const tavilyPlugin = {
364
682
 
365
683
  const res = await fetch(TAVILY_SEARCH_ENDPOINT, {
366
684
  method: "POST",
367
- headers: { "Content-Type": "application/json" },
685
+ headers: {
686
+ "Content-Type": "application/json",
687
+ Authorization: `Bearer ${apiKey}`,
688
+ },
368
689
  body: JSON.stringify(body),
369
690
  signal: controller.signal,
370
691
  });
@@ -426,6 +747,7 @@ const tavilyPlugin = {
426
747
  : {}),
427
748
  score: r.score,
428
749
  siteName: siteName(r.url) || undefined,
750
+ ...(includeFavicon && r.favicon ? { favicon: r.favicon } : {}),
429
751
  }));
430
752
 
431
753
  const payload: Record<string, unknown> = {
@@ -465,6 +787,464 @@ const tavilyPlugin = {
465
787
  { source: "openclaw-tavily" },
466
788
  );
467
789
 
790
+ // -----------------------------------------------------------------
791
+ // tavily_extract
792
+ // -----------------------------------------------------------------
793
+ api.registerTool(
794
+ {
795
+ name: "tavily_extract",
796
+ label: "Tavily Extract",
797
+ description:
798
+ "Extract and clean content from one or more URLs. Returns markdown or text content. " +
799
+ "Use when you need the full content of specific web pages.",
800
+ parameters: TavilyExtractSchema,
801
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
802
+ const urls = Array.isArray(params.urls)
803
+ ? (params.urls as string[]).filter((u) => typeof u === "string" && u.trim())
804
+ : [];
805
+ if (urls.length === 0) {
806
+ return {
807
+ content: [
808
+ {
809
+ type: "text" as const,
810
+ text: JSON.stringify({
811
+ error: "missing_urls",
812
+ message: "At least one URL is required.",
813
+ }),
814
+ },
815
+ ],
816
+ details: {},
817
+ };
818
+ }
819
+
820
+ const body: Record<string, unknown> = { urls };
821
+ if (typeof params.query === "string" && params.query.trim())
822
+ body.query = params.query.trim();
823
+ if (typeof params.chunks_per_source === "number")
824
+ body.chunks_per_source = params.chunks_per_source;
825
+ if (typeof params.format === "string" && ["markdown", "text"].includes(params.format))
826
+ body.format = params.format;
827
+ if (typeof params.include_favicon === "boolean")
828
+ body.include_favicon = params.include_favicon;
829
+ if (typeof params.timeout === "number")
830
+ body.timeout = params.timeout;
831
+
832
+ const start = Date.now();
833
+ try {
834
+ const controller = new AbortController();
835
+ const timer = setTimeout(() => controller.abort(), defaultTimeout * 1000);
836
+
837
+ const res = await fetch(TAVILY_EXTRACT_ENDPOINT, {
838
+ method: "POST",
839
+ headers: {
840
+ "Content-Type": "application/json",
841
+ Authorization: `Bearer ${apiKey}`,
842
+ },
843
+ body: JSON.stringify(body),
844
+ signal: controller.signal,
845
+ });
846
+ clearTimeout(timer);
847
+
848
+ if (!res.ok) {
849
+ let detail = "";
850
+ try { detail = await res.text(); } catch {}
851
+ api.logger.warn(`tavily extract: API error ${res.status}: ${detail || res.statusText}`);
852
+ return {
853
+ content: [{
854
+ type: "text" as const,
855
+ text: JSON.stringify({ error: "tavily_api_error", status: res.status, message: detail || res.statusText }, null, 2),
856
+ }],
857
+ details: {},
858
+ };
859
+ }
860
+
861
+ const data = await res.json() as Record<string, unknown>;
862
+ const tookMs = Date.now() - start;
863
+ const payload = { ...data, provider: "tavily", tookMs };
864
+
865
+ api.logger.info(`tavily extract: ${urls.length} URL(s) in ${tookMs}ms`);
866
+ return {
867
+ content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
868
+ details: {},
869
+ };
870
+ } catch (err) {
871
+ const msg = err instanceof Error ? err.message : String(err);
872
+ api.logger.warn(`tavily extract: fetch error: ${msg}`);
873
+ return {
874
+ content: [{
875
+ type: "text" as const,
876
+ text: JSON.stringify({ error: "tavily_fetch_error", message: msg }, null, 2),
877
+ }],
878
+ details: {},
879
+ };
880
+ }
881
+ },
882
+ },
883
+ { source: "openclaw-tavily" },
884
+ );
885
+
886
+ // -----------------------------------------------------------------
887
+ // tavily_crawl
888
+ // -----------------------------------------------------------------
889
+ api.registerTool(
890
+ {
891
+ name: "tavily_crawl",
892
+ label: "Tavily Crawl",
893
+ description:
894
+ "Crawl a website starting from a root URL. Traverses links and extracts content " +
895
+ "from discovered pages. Use for comprehensive site analysis.",
896
+ parameters: TavilyCrawlSchema,
897
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
898
+ const url = typeof params.url === "string" ? params.url.trim() : "";
899
+ if (!url) {
900
+ return {
901
+ content: [{
902
+ type: "text" as const,
903
+ text: JSON.stringify({ error: "missing_url", message: "A non-empty url is required." }),
904
+ }],
905
+ details: {},
906
+ };
907
+ }
908
+
909
+ const body: Record<string, unknown> = { url };
910
+ if (typeof params.instructions === "string" && params.instructions.trim())
911
+ body.instructions = params.instructions.trim();
912
+ if (typeof params.max_depth === "number")
913
+ body.max_depth = Math.max(1, Math.min(5, Math.floor(params.max_depth)));
914
+ if (typeof params.max_breadth === "number")
915
+ body.max_breadth = Math.max(1, Math.min(500, Math.floor(params.max_breadth)));
916
+ if (typeof params.limit === "number")
917
+ body.limit = Math.floor(params.limit);
918
+ if (Array.isArray(params.select_paths) && params.select_paths.length > 0)
919
+ body.select_paths = params.select_paths;
920
+ if (Array.isArray(params.select_domains) && params.select_domains.length > 0)
921
+ body.select_domains = params.select_domains;
922
+ if (Array.isArray(params.exclude_paths) && params.exclude_paths.length > 0)
923
+ body.exclude_paths = params.exclude_paths;
924
+ if (Array.isArray(params.exclude_domains) && params.exclude_domains.length > 0)
925
+ body.exclude_domains = params.exclude_domains;
926
+ if (typeof params.allow_external === "boolean")
927
+ body.allow_external = params.allow_external;
928
+ if (typeof params.include_images === "boolean")
929
+ body.include_images = params.include_images;
930
+ if (typeof params.extract_depth === "string" && ["basic", "advanced"].includes(params.extract_depth))
931
+ body.extract_depth = params.extract_depth;
932
+ if (typeof params.format === "string" && ["markdown", "text"].includes(params.format))
933
+ body.format = params.format;
934
+ if (typeof params.include_favicon === "boolean")
935
+ body.include_favicon = params.include_favicon;
936
+ if (typeof params.timeout === "number")
937
+ body.timeout = Math.max(10, Math.min(150, params.timeout));
938
+
939
+ const start = Date.now();
940
+ try {
941
+ const controller = new AbortController();
942
+ const timer = setTimeout(() => controller.abort(), defaultTimeout * 1000);
943
+
944
+ const res = await fetch(TAVILY_CRAWL_ENDPOINT, {
945
+ method: "POST",
946
+ headers: {
947
+ "Content-Type": "application/json",
948
+ Authorization: `Bearer ${apiKey}`,
949
+ },
950
+ body: JSON.stringify(body),
951
+ signal: controller.signal,
952
+ });
953
+ clearTimeout(timer);
954
+
955
+ if (!res.ok) {
956
+ let detail = "";
957
+ try { detail = await res.text(); } catch {}
958
+ api.logger.warn(`tavily crawl: API error ${res.status}: ${detail || res.statusText}`);
959
+ return {
960
+ content: [{
961
+ type: "text" as const,
962
+ text: JSON.stringify({ error: "tavily_api_error", status: res.status, message: detail || res.statusText }, null, 2),
963
+ }],
964
+ details: {},
965
+ };
966
+ }
967
+
968
+ const data = await res.json() as Record<string, unknown>;
969
+ const tookMs = Date.now() - start;
970
+ const payload = { ...data, provider: "tavily", tookMs };
971
+
972
+ const resultCount = Array.isArray(data.results) ? data.results.length : 0;
973
+ api.logger.info(`tavily crawl: ${url} → ${resultCount} pages in ${tookMs}ms`);
974
+ return {
975
+ content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
976
+ details: {},
977
+ };
978
+ } catch (err) {
979
+ const msg = err instanceof Error ? err.message : String(err);
980
+ api.logger.warn(`tavily crawl: fetch error: ${msg}`);
981
+ return {
982
+ content: [{
983
+ type: "text" as const,
984
+ text: JSON.stringify({ error: "tavily_fetch_error", message: msg }, null, 2),
985
+ }],
986
+ details: {},
987
+ };
988
+ }
989
+ },
990
+ },
991
+ { source: "openclaw-tavily" },
992
+ );
993
+
994
+ // -----------------------------------------------------------------
995
+ // tavily_map
996
+ // -----------------------------------------------------------------
997
+ api.registerTool(
998
+ {
999
+ name: "tavily_map",
1000
+ label: "Tavily Map",
1001
+ description:
1002
+ "Generate a site map — discover and list all URLs from a website. " +
1003
+ "Use to understand site structure before targeted extraction.",
1004
+ parameters: TavilyMapSchema,
1005
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
1006
+ const url = typeof params.url === "string" ? params.url.trim() : "";
1007
+ if (!url) {
1008
+ return {
1009
+ content: [{
1010
+ type: "text" as const,
1011
+ text: JSON.stringify({ error: "missing_url", message: "A non-empty url is required." }),
1012
+ }],
1013
+ details: {},
1014
+ };
1015
+ }
1016
+
1017
+ const body: Record<string, unknown> = { url };
1018
+ if (typeof params.instructions === "string" && params.instructions.trim())
1019
+ body.instructions = params.instructions.trim();
1020
+ if (typeof params.max_depth === "number")
1021
+ body.max_depth = Math.max(1, Math.min(5, Math.floor(params.max_depth)));
1022
+ if (typeof params.max_breadth === "number")
1023
+ body.max_breadth = Math.floor(params.max_breadth);
1024
+ if (typeof params.limit === "number")
1025
+ body.limit = Math.floor(params.limit);
1026
+ if (Array.isArray(params.select_paths) && params.select_paths.length > 0)
1027
+ body.select_paths = params.select_paths;
1028
+ if (Array.isArray(params.select_domains) && params.select_domains.length > 0)
1029
+ body.select_domains = params.select_domains;
1030
+ if (Array.isArray(params.exclude_paths) && params.exclude_paths.length > 0)
1031
+ body.exclude_paths = params.exclude_paths;
1032
+ if (Array.isArray(params.exclude_domains) && params.exclude_domains.length > 0)
1033
+ body.exclude_domains = params.exclude_domains;
1034
+ if (typeof params.allow_external === "boolean")
1035
+ body.allow_external = params.allow_external;
1036
+ if (typeof params.categories === "string" && params.categories.trim())
1037
+ body.categories = params.categories.trim();
1038
+
1039
+ const start = Date.now();
1040
+ try {
1041
+ const controller = new AbortController();
1042
+ const timer = setTimeout(() => controller.abort(), defaultTimeout * 1000);
1043
+
1044
+ const res = await fetch(TAVILY_MAP_ENDPOINT, {
1045
+ method: "POST",
1046
+ headers: {
1047
+ "Content-Type": "application/json",
1048
+ Authorization: `Bearer ${apiKey}`,
1049
+ },
1050
+ body: JSON.stringify(body),
1051
+ signal: controller.signal,
1052
+ });
1053
+ clearTimeout(timer);
1054
+
1055
+ if (!res.ok) {
1056
+ let detail = "";
1057
+ try { detail = await res.text(); } catch {}
1058
+ api.logger.warn(`tavily map: API error ${res.status}: ${detail || res.statusText}`);
1059
+ return {
1060
+ content: [{
1061
+ type: "text" as const,
1062
+ text: JSON.stringify({ error: "tavily_api_error", status: res.status, message: detail || res.statusText }, null, 2),
1063
+ }],
1064
+ details: {},
1065
+ };
1066
+ }
1067
+
1068
+ const data = await res.json() as Record<string, unknown>;
1069
+ const tookMs = Date.now() - start;
1070
+ const payload = { ...data, provider: "tavily", tookMs };
1071
+
1072
+ const urlCount = Array.isArray(data.results) ? data.results.length : 0;
1073
+ api.logger.info(`tavily map: ${url} → ${urlCount} URLs in ${tookMs}ms`);
1074
+ return {
1075
+ content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
1076
+ details: {},
1077
+ };
1078
+ } catch (err) {
1079
+ const msg = err instanceof Error ? err.message : String(err);
1080
+ api.logger.warn(`tavily map: fetch error: ${msg}`);
1081
+ return {
1082
+ content: [{
1083
+ type: "text" as const,
1084
+ text: JSON.stringify({ error: "tavily_fetch_error", message: msg }, null, 2),
1085
+ }],
1086
+ details: {},
1087
+ };
1088
+ }
1089
+ },
1090
+ },
1091
+ { source: "openclaw-tavily" },
1092
+ );
1093
+
1094
+ // -----------------------------------------------------------------
1095
+ // tavily_research
1096
+ // -----------------------------------------------------------------
1097
+ api.registerTool(
1098
+ {
1099
+ name: "tavily_research",
1100
+ label: "Tavily Research",
1101
+ description:
1102
+ "Run a deep agentic research task. Tavily performs multi-step search and analysis, " +
1103
+ "returning a comprehensive report. Use for complex research questions.",
1104
+ parameters: TavilyResearchSchema,
1105
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
1106
+ const input = typeof params.input === "string" ? params.input.trim() : "";
1107
+ if (!input) {
1108
+ return {
1109
+ content: [{
1110
+ type: "text" as const,
1111
+ text: JSON.stringify({ error: "missing_input", message: "A non-empty input is required." }),
1112
+ }],
1113
+ details: {},
1114
+ };
1115
+ }
1116
+
1117
+ const body: Record<string, unknown> = { input };
1118
+ if (typeof params.model === "string" && ["mini", "pro", "auto"].includes(params.model))
1119
+ body.model = params.model;
1120
+ if (params.output_schema && typeof params.output_schema === "object")
1121
+ body.output_schema = params.output_schema;
1122
+ if (typeof params.citation_format === "string" && ["numbered", "mla", "apa", "chicago"].includes(params.citation_format))
1123
+ body.citation_format = params.citation_format;
1124
+
1125
+ // Research is async — POST to create, then poll GET until complete
1126
+ const RESEARCH_POLL_INTERVAL = 2000; // 2s between polls
1127
+ const RESEARCH_MAX_WAIT = defaultTimeout * 5 * 1000; // 5x default timeout
1128
+
1129
+ const start = Date.now();
1130
+ try {
1131
+ // Step 1: Create the research task
1132
+ const createRes = await fetch(TAVILY_RESEARCH_ENDPOINT, {
1133
+ method: "POST",
1134
+ headers: {
1135
+ "Content-Type": "application/json",
1136
+ Authorization: `Bearer ${apiKey}`,
1137
+ },
1138
+ body: JSON.stringify(body),
1139
+ });
1140
+
1141
+ if (!createRes.ok) {
1142
+ let detail = "";
1143
+ try { detail = await createRes.text(); } catch {}
1144
+ api.logger.warn(`tavily research: API error ${createRes.status}: ${detail || createRes.statusText}`);
1145
+ return {
1146
+ content: [{
1147
+ type: "text" as const,
1148
+ text: JSON.stringify({ error: "tavily_api_error", status: createRes.status, message: detail || createRes.statusText }, null, 2),
1149
+ }],
1150
+ details: {},
1151
+ };
1152
+ }
1153
+
1154
+ const createData = await createRes.json() as Record<string, unknown>;
1155
+ const requestId = createData.request_id as string | undefined;
1156
+
1157
+ // If the response already has content/output (not pending), return it
1158
+ if (createData.status !== "pending" || !requestId) {
1159
+ const tookMs = Date.now() - start;
1160
+ const payload = { ...createData, provider: "tavily", tookMs };
1161
+ api.logger.info(`tavily research: "${input.slice(0, 60)}" immediate in ${tookMs}ms`);
1162
+ return {
1163
+ content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
1164
+ details: {},
1165
+ };
1166
+ }
1167
+
1168
+ // Step 2: Poll until complete or timeout
1169
+ api.logger.info(`tavily research: polling ${requestId} for "${input.slice(0, 60)}"...`);
1170
+ const pollUrl = `${TAVILY_RESEARCH_ENDPOINT}/${requestId}`;
1171
+
1172
+ while (Date.now() - start < RESEARCH_MAX_WAIT) {
1173
+ await new Promise((r) => setTimeout(r, RESEARCH_POLL_INTERVAL));
1174
+
1175
+ const pollRes = await fetch(pollUrl, {
1176
+ method: "GET",
1177
+ headers: { Authorization: `Bearer ${apiKey}` },
1178
+ });
1179
+
1180
+ if (!pollRes.ok) {
1181
+ let detail = "";
1182
+ try { detail = await pollRes.text(); } catch {}
1183
+ api.logger.warn(`tavily research: poll error ${pollRes.status}: ${detail || pollRes.statusText}`);
1184
+ return {
1185
+ content: [{
1186
+ type: "text" as const,
1187
+ text: JSON.stringify({ error: "tavily_api_error", status: pollRes.status, message: detail || pollRes.statusText }, null, 2),
1188
+ }],
1189
+ details: {},
1190
+ };
1191
+ }
1192
+
1193
+ const pollData = await pollRes.json() as Record<string, unknown>;
1194
+
1195
+ if (pollData.status === "completed" || pollData.content || pollData.output) {
1196
+ const tookMs = Date.now() - start;
1197
+ const payload = { ...pollData, provider: "tavily", tookMs };
1198
+ api.logger.info(`tavily research: "${input.slice(0, 60)}" completed in ${tookMs}ms`);
1199
+ return {
1200
+ content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
1201
+ details: {},
1202
+ };
1203
+ }
1204
+
1205
+ if (pollData.status === "failed" || pollData.status === "error") {
1206
+ api.logger.warn(`tavily research: task failed: ${JSON.stringify(pollData)}`);
1207
+ return {
1208
+ content: [{
1209
+ type: "text" as const,
1210
+ text: JSON.stringify({ error: "tavily_research_failed", ...pollData }, null, 2),
1211
+ }],
1212
+ details: {},
1213
+ };
1214
+ }
1215
+ // still pending — continue polling
1216
+ }
1217
+
1218
+ // Timeout
1219
+ const tookMs = Date.now() - start;
1220
+ api.logger.warn(`tavily research: timed out after ${tookMs}ms for "${input.slice(0, 60)}"`);
1221
+ return {
1222
+ content: [{
1223
+ type: "text" as const,
1224
+ text: JSON.stringify({
1225
+ error: "tavily_research_timeout",
1226
+ message: `Research task ${requestId} still pending after ${Math.round(tookMs / 1000)}s. Try again later.`,
1227
+ requestId,
1228
+ }, null, 2),
1229
+ }],
1230
+ details: {},
1231
+ };
1232
+ } catch (err) {
1233
+ const msg = err instanceof Error ? err.message : String(err);
1234
+ api.logger.warn(`tavily research: fetch error: ${msg}`);
1235
+ return {
1236
+ content: [{
1237
+ type: "text" as const,
1238
+ text: JSON.stringify({ error: "tavily_fetch_error", message: msg }, null, 2),
1239
+ }],
1240
+ details: {},
1241
+ };
1242
+ }
1243
+ },
1244
+ },
1245
+ { source: "openclaw-tavily" },
1246
+ );
1247
+
468
1248
  api.registerService({
469
1249
  id: "openclaw-tavily",
470
1250
  start: () => api.logger.info("tavily: service started"),