@openclaw/exa-plugin 2026.10.10-beta.5 → 2026.11.5-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
2
2
  import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-contract";
3
- //#region extensions/exa/src/exa-web-search-provider.ts
3
+ // extensions/exa/src/exa-web-search-provider.ts
4
4
  const EXA_CREDENTIAL_PATH = "plugins.entries.exa.config.webSearch.apiKey";
5
5
  const EXA_SEARCH_TYPES = [
6
6
  "auto",
@@ -17,7 +17,7 @@ const EXA_FRESHNESS_VALUES = [
17
17
  "year"
18
18
  ];
19
19
  const EXA_MAX_SEARCH_COUNT = 100;
20
- const loadExaWebSearchRuntime = createLazyRuntimeModule(() => import("./exa-web-search-provider.runtime-BHMevXlO.mjs"));
20
+ const loadExaWebSearchRuntime = createLazyRuntimeModule(() => import("./exa-web-search-provider.runtime-Cyx_Cn4E.mjs"));
21
21
  const ExaSearchSchema = {
22
22
  type: "object",
23
23
  properties: {
@@ -94,5 +94,4 @@ function createExaWebSearchProvider() {
94
94
  })
95
95
  };
96
96
  }
97
- //#endregion
98
97
  export { createExaWebSearchProvider as t };
@@ -0,0 +1,234 @@
1
+ import { sha256Hex } from "openclaw/plugin-sdk/node-crypto-runtime";
2
+ import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
3
+ import { ProviderHttpError, readProviderResponseErrorText, redactProviderResponseErrorText } from "openclaw/plugin-sdk/provider-http";
4
+ import { DEFAULT_SEARCH_COUNT, buildSearchCacheKey, externalContentStamp, mergeScopedSearchConfig, parseIsoDateRange, readCachedSearchPayload, readPositiveIntegerParam, readStringParam, readWebSearchApiKey, resolveProviderWebSearchPluginConfig, resolveSearchCacheTtlMs, resolveSearchTimeoutSeconds, resolveSiteName, withTrustedWebSearchEndpoint, wrapWebContent, writeCachedSearchPayload } from "openclaw/plugin-sdk/provider-web-search";
5
+ import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
6
+ import { asOptionalObjectRecord, asOptionalRecord, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
7
+ // extensions/exa/src/exa-web-search-provider.runtime.ts
8
+ const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
9
+ const EXA_SEARCH_TYPES = [
10
+ "auto",
11
+ "neural",
12
+ "fast",
13
+ "deep",
14
+ "deep-reasoning",
15
+ "instant"
16
+ ];
17
+ const EXA_FRESHNESS_VALUES = [
18
+ "day",
19
+ "week",
20
+ "month",
21
+ "year"
22
+ ];
23
+ const EXA_MAX_SEARCH_COUNT = 100;
24
+ const EXA_ERROR_BODY_LIMIT_BYTES = 8192;
25
+ const EXA_SEARCH_JSON_MAX_BYTES = 16777216;
26
+ function invalidBaseUrlPayload() {
27
+ return {
28
+ error: "invalid_base_url",
29
+ message: "plugins.entries.exa.config.webSearch.baseUrl must be a valid http(s) URL.",
30
+ docs: "https://docs.openclaw.ai/tools/exa-search"
31
+ };
32
+ }
33
+ function invalidContentsPayload(message) {
34
+ return {
35
+ error: "invalid_contents",
36
+ message,
37
+ docs: "https://docs.openclaw.ai/tools/web"
38
+ };
39
+ }
40
+ async function executeExaWebSearchProviderTool(ctx, args, signal) {
41
+ const searchConfig = mergeScopedSearchConfig(ctx.searchConfig, "exa", resolveProviderWebSearchPluginConfig(ctx.config, "exa"));
42
+ const exaConfig = asOptionalRecord(searchConfig?.exa);
43
+ const apiKey = readWebSearchApiKey(exaConfig?.apiKey, "plugins.entries.exa.config.webSearch.apiKey", ["EXA_API_KEY"]);
44
+ if (!apiKey) return {
45
+ error: "missing_exa_api_key",
46
+ message: "web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure plugins.entries.exa.config.webSearch.apiKey.",
47
+ docs: "https://docs.openclaw.ai/tools/web"
48
+ };
49
+ let endpoint = EXA_SEARCH_ENDPOINT;
50
+ const configured = normalizeOptionalString(exaConfig?.baseUrl);
51
+ if (configured) {
52
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(configured) && !/^https?:\/\//i.test(configured)) return invalidBaseUrlPayload();
53
+ const candidate = /^https?:\/\//i.test(configured) ? configured : `https://${configured}`;
54
+ let parsed;
55
+ try {
56
+ parsed = new URL(candidate);
57
+ } catch {
58
+ return invalidBaseUrlPayload();
59
+ }
60
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return invalidBaseUrlPayload();
61
+ const pathname = parsed.pathname.replace(/\/+$/, "");
62
+ parsed.pathname = pathname.endsWith("/search") ? pathname : `${pathname}/search`;
63
+ parsed.hash = "";
64
+ endpoint = parsed.toString();
65
+ }
66
+ const query = readStringParam(args, "query", { required: true });
67
+ const rawType = readStringParam(args, "type");
68
+ const type = EXA_SEARCH_TYPES.find((candidate) => candidate === rawType) ?? "auto";
69
+ const count = readPositiveIntegerParam(args, "count", {
70
+ max: EXA_MAX_SEARCH_COUNT,
71
+ message: `count must be an integer from 1 to ${EXA_MAX_SEARCH_COUNT}.`
72
+ }) ?? searchConfig?.maxResults;
73
+ const rawFreshness = readStringParam(args, "freshness");
74
+ const normalizedFreshness = normalizeOptionalLowercaseString(rawFreshness);
75
+ const freshness = EXA_FRESHNESS_VALUES.find((value) => value === normalizedFreshness);
76
+ if (rawFreshness && !freshness) return {
77
+ error: "invalid_freshness",
78
+ message: "freshness must be one of \"day\", \"week\", \"month\", or \"year\".",
79
+ docs: "https://docs.openclaw.ai/tools/web"
80
+ };
81
+ const rawDateAfter = readStringParam(args, "date_after");
82
+ const rawDateBefore = readStringParam(args, "date_before");
83
+ if (freshness && (rawDateAfter || rawDateBefore)) return {
84
+ error: "conflicting_time_filters",
85
+ message: "freshness cannot be combined with date_after or date_before. Use one time-filter mode.",
86
+ docs: "https://docs.openclaw.ai/tools/web"
87
+ };
88
+ const parsedDateRange = parseIsoDateRange({
89
+ rawDateAfter,
90
+ rawDateBefore,
91
+ invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
92
+ invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
93
+ invalidDateRangeMessage: "date_after must be earlier than or equal to date_before."
94
+ });
95
+ if ("error" in parsedDateRange) return parsedDateRange;
96
+ const { dateAfter, dateBefore } = parsedDateRange;
97
+ const parsedContents = {};
98
+ const rawContents = args.contents;
99
+ if (rawContents !== void 0) {
100
+ if (!isRecord(rawContents)) return invalidContentsPayload("contents must be an object with optional text, highlights, and summary fields.");
101
+ const allowedKeys = /* @__PURE__ */ new Set([
102
+ "text",
103
+ "highlights",
104
+ "summary"
105
+ ]);
106
+ for (const key of Object.keys(rawContents)) if (!allowedKeys.has(key)) return invalidContentsPayload(`contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`);
107
+ const fieldsBySection = {
108
+ text: ["maxCharacters"],
109
+ highlights: [
110
+ "maxCharacters",
111
+ "query",
112
+ "numSentences",
113
+ "highlightsPerUrl"
114
+ ],
115
+ summary: ["query"]
116
+ };
117
+ for (const section of [
118
+ "text",
119
+ "highlights",
120
+ "summary"
121
+ ]) {
122
+ if (!(section in rawContents)) continue;
123
+ const value = rawContents[section];
124
+ if (typeof value === "boolean") {
125
+ parsedContents[section] = value;
126
+ continue;
127
+ }
128
+ if (!isRecord(value)) return invalidContentsPayload(`contents.${section} must be a boolean or an object.`);
129
+ const option = value;
130
+ const fields = fieldsBySection[section];
131
+ for (const key of Object.keys(option)) if (!fields.includes(key)) return invalidContentsPayload(`contents.${section} has unknown field "${key}". ${section === "highlights" ? "Allowed fields are \"maxCharacters\", \"query\", \"numSentences\", and \"highlightsPerUrl\"." : `Only "${fields[0]}" is allowed.`}`);
132
+ for (const field of fields) if (field !== "query" && field in option && !(typeof option[field] === "number" && Number.isInteger(option[field]) && option[field] > 0)) return invalidContentsPayload(`contents.${section}.${field} must be a positive integer.`);
133
+ if (section !== "text" && "query" in option && typeof option.query !== "string") return invalidContentsPayload(`contents.${section}.query must be a string.`);
134
+ const normalized = {};
135
+ for (const field of fields) if (field in option) normalized[field] = option[field];
136
+ Object.assign(parsedContents, { [section]: normalized });
137
+ }
138
+ }
139
+ const contents = Object.keys(parsedContents).length > 0 ? parsedContents : void 0;
140
+ const resolvedCount = Math.min(EXA_MAX_SEARCH_COUNT, parseStrictPositiveInteger(count) ?? DEFAULT_SEARCH_COUNT);
141
+ const cacheKey = buildSearchCacheKey([
142
+ "exa",
143
+ sha256Hex(apiKey),
144
+ endpoint,
145
+ type,
146
+ query,
147
+ resolvedCount,
148
+ freshness,
149
+ dateAfter,
150
+ dateBefore,
151
+ JSON.stringify(contents ?? { highlights: true })
152
+ ]);
153
+ const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
154
+ const cached = readCachedSearchPayload(cacheKey, cacheTtlMs);
155
+ if (cached) return cached;
156
+ const start = Date.now();
157
+ const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
158
+ const body = {
159
+ query,
160
+ numResults: resolvedCount,
161
+ type,
162
+ contents: contents ?? { highlights: true }
163
+ };
164
+ if (dateAfter) body.startPublishedDate = dateAfter;
165
+ else if (freshness) {
166
+ const now = /* @__PURE__ */ new Date();
167
+ if (freshness === "day") now.setUTCDate(now.getUTCDate() - 1);
168
+ else if (freshness === "week") now.setUTCDate(now.getUTCDate() - 7);
169
+ else if (freshness === "month") {
170
+ const currentDay = now.getUTCDate();
171
+ now.setUTCDate(1);
172
+ now.setUTCMonth(now.getUTCMonth() - 1);
173
+ const lastDayOfTargetMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate();
174
+ now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth));
175
+ } else now.setUTCFullYear(now.getUTCFullYear() - 1);
176
+ body.startPublishedDate = now.toISOString();
177
+ }
178
+ if (dateBefore) body.endPublishedDate = dateBefore;
179
+ const headers = {
180
+ Accept: "application/json",
181
+ "Content-Type": "application/json",
182
+ "x-api-key": apiKey,
183
+ "x-exa-integration": "openclaw"
184
+ };
185
+ const results = await withTrustedWebSearchEndpoint({
186
+ url: endpoint,
187
+ timeoutSeconds,
188
+ signal,
189
+ init: {
190
+ method: "POST",
191
+ headers,
192
+ body: JSON.stringify(body)
193
+ }
194
+ }, async (res) => {
195
+ if (!res.ok) {
196
+ const detail = await readProviderResponseErrorText(res, EXA_ERROR_BODY_LIMIT_BYTES, headers, signal);
197
+ throw new ProviderHttpError(`Exa API error (${res.status}): ${detail || redactProviderResponseErrorText(res.statusText, headers)}`, { status: res.status });
198
+ }
199
+ const bytes = await readResponseWithLimit(res, EXA_SEARCH_JSON_MAX_BYTES, { onOverflow: ({ maxBytes: maxBytesLocal }) => /* @__PURE__ */ new Error(`Exa API response exceeds ${maxBytesLocal} bytes`) });
200
+ try {
201
+ const payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
202
+ const rows = asOptionalObjectRecord(payload)?.results;
203
+ return (Array.isArray(rows) ? rows.filter(isRecord) : []).slice(0, resolvedCount);
204
+ } catch (cause) {
205
+ throw new Error("Exa API returned malformed JSON", { cause });
206
+ }
207
+ });
208
+ signal?.throwIfAborted();
209
+ const payload = {
210
+ query,
211
+ provider: "exa",
212
+ count: results.length,
213
+ tookMs: Date.now() - start,
214
+ externalContent: externalContentStamp("exa"),
215
+ results: results.map((entry) => {
216
+ const title = typeof entry.title === "string" ? entry.title : "";
217
+ const url = typeof entry.url === "string" ? entry.url : "";
218
+ const description = normalizeTrimmedStringList(entry.highlights).join("\n") || normalizeOptionalString(entry.summary) || normalizeOptionalString(entry.text) || "";
219
+ const summary = normalizeOptionalString(entry.summary) ?? "";
220
+ const highlightScores = Array.isArray(entry.highlightScores) ? entry.highlightScores.filter((score) => typeof score === "number" && Number.isFinite(score)) : [];
221
+ const published = typeof entry.publishedDate === "string" && entry.publishedDate ? entry.publishedDate : void 0;
222
+ return Object.assign({
223
+ title: title ? wrapWebContent(title, `web_search`) : ``,
224
+ url,
225
+ description: description ? wrapWebContent(description, `web_search`) : ``,
226
+ published,
227
+ siteName: resolveSiteName(url) || void 0
228
+ }, summary ? { summary: wrapWebContent(summary, `web_search`) } : {}, highlightScores.length > 0 ? { highlightScores } : {});
229
+ })
230
+ };
231
+ writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
232
+ return payload;
233
+ }
234
+ export { executeExaWebSearchProviderTool };
@@ -1 +1 @@
1
- {"version":3,"entries":[{"output":"index.js","source":"extensions/exa/index.ts"},{"output":"web-search-contract-api.js","source":"extensions/exa/web-search-contract-api.ts"},{"output":"web-search-provider.js","source":"extensions/exa/web-search-provider.ts"}],"chunks":[{"output":".setup/exa-web-search-provider-D4XL4FbA.mjs","sha256":"ff4076f65d5805620dd6bef6926a8af9532bda8ebeb16a19d9d397d2bfacd8a8","facadeSource":null,"sources":["extensions/exa/src/exa-web-search-provider.ts"]},{"output":".setup/exa-web-search-provider.runtime-BHMevXlO.mjs","sha256":"a72abd12d5fc1be0282b78c4a20bc47dfec001a2c507019008a4a2d14411f6b5","facadeSource":"extensions/exa/src/exa-web-search-provider.runtime.ts","sources":["extensions/exa/src/exa-web-search-provider.runtime.ts"]},{"output":"index.js","sha256":"93e440b278672a33fcbe10570119060f8f414632fbe05bf04771b65b038397f4","facadeSource":"extensions/exa/index.ts","sources":["extensions/exa/index.ts"]},{"output":"web-search-contract-api.js","sha256":"8b9afbbff151a0c846cfac5561bd89972d67ba43d6057eb728b23282526ad6ad","facadeSource":"extensions/exa/web-search-contract-api.ts","sources":[]},{"output":"web-search-provider.js","sha256":"ebb4426904f3597aac31c81c3adb691a0ad470a9c000514bf528ceca90dff029","facadeSource":"extensions/exa/web-search-provider.ts","sources":[]}],"unrenderedSources":["extensions/exa/web-search-contract-api.ts","extensions/exa/web-search-provider.ts"]}
1
+ {"version":3,"entries":[{"output":"index.js","source":"extensions/exa/index.ts"},{"output":"web-search-contract-api.js","source":"extensions/exa/web-search-contract-api.ts"},{"output":"web-search-provider.js","source":"extensions/exa/web-search-provider.ts"}],"chunks":[{"output":".setup/exa-web-search-provider-9ilJPxIa.mjs","sha256":"7848e32ecdf272b34d2b848c2c598860a056286519108ef2332adbff7f57e4bb","facadeSource":null,"sources":["extensions/exa/src/exa-web-search-provider.ts"]},{"output":".setup/exa-web-search-provider.runtime-Cyx_Cn4E.mjs","sha256":"6c96f753cdc3988c4c61316af73322de9ea892ab44cc5a1564487ba2d80eb88b","facadeSource":"extensions/exa/src/exa-web-search-provider.runtime.ts","sources":["extensions/exa/src/exa-web-search-provider.runtime.ts"]},{"output":"index.js","sha256":"f0c0714147e48fb96b10c63c71086ae926b95410fd15da94c3512300ce3e4f05","facadeSource":"extensions/exa/index.ts","sources":["extensions/exa/index.ts"]},{"output":"web-search-contract-api.js","sha256":"62cf1f37c9b0231bbefb3e888395ee4dce094e3da087bacef986b9e30d91fe7e","facadeSource":"extensions/exa/web-search-contract-api.ts","sources":[]},{"output":"web-search-provider.js","sha256":"63a6db6f966c7c513ce0ad97e1afb68c5ad6be28866dad8de9c6d8b311d25848","facadeSource":"extensions/exa/web-search-provider.ts","sources":[]}],"unrenderedSources":["extensions/exa/web-search-contract-api.ts","extensions/exa/web-search-provider.ts"]}
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { t as createExaWebSearchProvider } from "./.setup/exa-web-search-provider-D4XL4FbA.mjs";
1
+ import { t as createExaWebSearchProvider } from "./.setup/exa-web-search-provider-9ilJPxIa.mjs";
2
2
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
3
- //#region extensions/exa/index.ts
3
+ // extensions/exa/index.ts
4
4
  var exa_default = definePluginEntry({
5
5
  id: "exa",
6
6
  name: "Exa Plugin",
@@ -9,5 +9,4 @@ var exa_default = definePluginEntry({
9
9
  api.registerWebSearchProvider(createExaWebSearchProvider());
10
10
  }
11
11
  });
12
- //#endregion
13
12
  export { exa_default as default };
@@ -1,2 +1,2 @@
1
- export { t as createExaWebSearchProvider } from "./.setup/exa-web-search-provider-D4XL4FbA.mjs";
1
+ export { t as createExaWebSearchProvider } from "./.setup/exa-web-search-provider-9ilJPxIa.mjs";
2
2
  import "./web-search-provider.js";
@@ -1 +1 @@
1
- export { t as createExaWebSearchProvider } from "./.setup/exa-web-search-provider-D4XL4FbA.mjs";
1
+ export { t as createExaWebSearchProvider } from "./.setup/exa-web-search-provider-9ilJPxIa.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/exa-plugin",
3
- "version": "2026.10.10-beta.5",
3
+ "version": "2026.11.5-beta.1",
4
4
  "description": "OpenClaw Exa plugin.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,10 +18,10 @@
18
18
  "minHostVersion": ">=2026.6.8"
19
19
  },
20
20
  "compat": {
21
- "pluginApi": ">=2026.10.10-beta.5"
21
+ "pluginApi": ">=2026.11.5-beta.1"
22
22
  },
23
23
  "build": {
24
- "openclawVersion": "2026.10.10-beta.5",
24
+ "openclawVersion": "2026.11.5-beta.1",
25
25
  "bundledDist": false
26
26
  },
27
27
  "release": {
@@ -40,7 +40,7 @@
40
40
  "assets/activity.svg"
41
41
  ],
42
42
  "peerDependencies": {
43
- "openclaw": ">=2026.10.10-beta.5"
43
+ "openclaw": ">=2026.11.5-beta.1"
44
44
  },
45
45
  "peerDependenciesMeta": {
46
46
  "openclaw": {
@@ -1,262 +0,0 @@
1
- import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
2
- import { ProviderHttpError, readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
3
- import { DEFAULT_SEARCH_COUNT, buildSearchCacheKey, mergeScopedSearchConfig, parseIsoDateRange, readCachedSearchPayload, readConfiguredSecretString, readPositiveIntegerParam, readProviderEnvValue, readStringParam, resolveProviderWebSearchPluginConfig, resolveSearchCacheTtlMs, resolveSearchTimeoutSeconds, resolveSiteName, withTrustedWebSearchEndpoint, wrapWebContent, writeCachedSearchPayload } from "openclaw/plugin-sdk/provider-web-search";
4
- import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
5
- import { asOptionalObjectRecord, asOptionalRecord, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
6
- //#region extensions/exa/src/exa-web-search-provider.runtime.ts
7
- const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
8
- const EXA_SEARCH_TYPES = [
9
- "auto",
10
- "neural",
11
- "fast",
12
- "deep",
13
- "deep-reasoning",
14
- "instant"
15
- ];
16
- const EXA_FRESHNESS_VALUES = [
17
- "day",
18
- "week",
19
- "month",
20
- "year"
21
- ];
22
- const EXA_MAX_SEARCH_COUNT = 100;
23
- const EXA_ERROR_BODY_LIMIT_BYTES = 8192;
24
- const EXA_SEARCH_JSON_MAX_BYTES = 16777216;
25
- async function readExaSearchResults(response) {
26
- const bytes = await readResponseWithLimit(response, EXA_SEARCH_JSON_MAX_BYTES, { onOverflow: ({ maxBytes: maxBytesLocal }) => /* @__PURE__ */ new Error(`Exa API response exceeds ${maxBytesLocal} bytes`) });
27
- try {
28
- const payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
29
- const results = asOptionalObjectRecord(payload)?.results;
30
- return Array.isArray(results) ? results.filter(isRecord) : [];
31
- } catch (cause) {
32
- throw new Error("Exa API returned malformed JSON", { cause });
33
- }
34
- }
35
- function normalizeExaFreshness(value) {
36
- const trimmed = normalizeOptionalLowercaseString(value);
37
- return EXA_FRESHNESS_VALUES.find((freshness) => freshness === trimmed);
38
- }
39
- function resolveExaApiKey(exa) {
40
- return readConfiguredSecretString(exa?.apiKey, "plugins.entries.exa.config.webSearch.apiKey") ?? readProviderEnvValue(["EXA_API_KEY"]);
41
- }
42
- function invalidBaseUrlPayload() {
43
- return {
44
- error: "invalid_base_url",
45
- message: "plugins.entries.exa.config.webSearch.baseUrl must be a valid http(s) URL.",
46
- docs: "https://docs.openclaw.ai/tools/exa-search"
47
- };
48
- }
49
- function resolveExaSearchEndpoint(exa) {
50
- const configured = normalizeOptionalString(exa?.baseUrl);
51
- if (!configured) return { endpoint: EXA_SEARCH_ENDPOINT };
52
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(configured) && !/^https?:\/\//i.test(configured)) return invalidBaseUrlPayload();
53
- const candidate = /^https?:\/\//i.test(configured) ? configured : `https://${configured}`;
54
- let parsed;
55
- try {
56
- parsed = new URL(candidate);
57
- } catch {
58
- return invalidBaseUrlPayload();
59
- }
60
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return invalidBaseUrlPayload();
61
- const pathname = parsed.pathname.replace(/\/+$/, "");
62
- parsed.pathname = pathname.endsWith("/search") ? pathname : `${pathname}/search`;
63
- parsed.hash = "";
64
- return { endpoint: parsed.toString() };
65
- }
66
- function invalidContentsPayload(message) {
67
- return {
68
- error: "invalid_contents",
69
- message,
70
- docs: "https://docs.openclaw.ai/tools/web"
71
- };
72
- }
73
- function parseExaContents(rawContents) {
74
- if (rawContents === void 0) return { value: void 0 };
75
- if (!isRecord(rawContents)) return invalidContentsPayload("contents must be an object with optional text, highlights, and summary fields.");
76
- const raw = rawContents;
77
- const allowedKeys = /* @__PURE__ */ new Set([
78
- "text",
79
- "highlights",
80
- "summary"
81
- ]);
82
- for (const key of Object.keys(raw)) if (!allowedKeys.has(key)) return invalidContentsPayload(`contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`);
83
- const parsed = {};
84
- const fieldsBySection = {
85
- text: ["maxCharacters"],
86
- highlights: [
87
- "maxCharacters",
88
- "query",
89
- "numSentences",
90
- "highlightsPerUrl"
91
- ],
92
- summary: ["query"]
93
- };
94
- for (const section of [
95
- "text",
96
- "highlights",
97
- "summary"
98
- ]) {
99
- if (!(section in raw)) continue;
100
- const value = raw[section];
101
- if (typeof value === "boolean") {
102
- parsed[section] = value;
103
- continue;
104
- }
105
- if (!isRecord(value)) return invalidContentsPayload(`contents.${section} must be a boolean or an object.`);
106
- const option = value;
107
- const fields = fieldsBySection[section];
108
- for (const key of Object.keys(option)) if (!fields.includes(key)) return invalidContentsPayload(`contents.${section} has unknown field "${key}". ${section === "highlights" ? "Allowed fields are \"maxCharacters\", \"query\", \"numSentences\", and \"highlightsPerUrl\"." : `Only "${fields[0]}" is allowed.`}`);
109
- for (const field of fields) if (field !== "query" && field in option && !(typeof option[field] === "number" && Number.isInteger(option[field]) && option[field] > 0)) return invalidContentsPayload(`contents.${section}.${field} must be a positive integer.`);
110
- if (section !== "text" && "query" in option && typeof option.query !== "string") return invalidContentsPayload(`contents.${section}.query must be a string.`);
111
- const normalized = {};
112
- for (const field of fields) if (field in option) normalized[field] = option[field];
113
- Object.assign(parsed, { [section]: normalized });
114
- }
115
- return { value: parsed };
116
- }
117
- function resolveFreshnessStartDate(freshness) {
118
- const now = /* @__PURE__ */ new Date();
119
- if (freshness === "day") {
120
- now.setUTCDate(now.getUTCDate() - 1);
121
- return now.toISOString();
122
- }
123
- if (freshness === "week") {
124
- now.setUTCDate(now.getUTCDate() - 7);
125
- return now.toISOString();
126
- }
127
- if (freshness === "month") {
128
- const currentDay = now.getUTCDate();
129
- now.setUTCDate(1);
130
- now.setUTCMonth(now.getUTCMonth() - 1);
131
- const lastDayOfTargetMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate();
132
- now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth));
133
- return now.toISOString();
134
- }
135
- now.setUTCFullYear(now.getUTCFullYear() - 1);
136
- return now.toISOString();
137
- }
138
- async function executeExaWebSearchProviderTool(ctx, args, signal) {
139
- const searchConfig = mergeScopedSearchConfig(ctx.searchConfig, "exa", resolveProviderWebSearchPluginConfig(ctx.config, "exa"));
140
- const exaConfig = asOptionalRecord(searchConfig?.exa);
141
- const apiKey = resolveExaApiKey(exaConfig);
142
- if (!apiKey) return {
143
- error: "missing_exa_api_key",
144
- message: "web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure plugins.entries.exa.config.webSearch.apiKey.",
145
- docs: "https://docs.openclaw.ai/tools/web"
146
- };
147
- const endpointResult = resolveExaSearchEndpoint(exaConfig);
148
- if ("error" in endpointResult) return endpointResult;
149
- const endpoint = endpointResult.endpoint;
150
- const query = readStringParam(args, "query", { required: true });
151
- const rawType = readStringParam(args, "type");
152
- const type = EXA_SEARCH_TYPES.find((candidate) => candidate === rawType) ?? "auto";
153
- const count = readPositiveIntegerParam(args, "count", {
154
- max: EXA_MAX_SEARCH_COUNT,
155
- message: `count must be an integer from 1 to ${EXA_MAX_SEARCH_COUNT}.`
156
- }) ?? searchConfig?.maxResults ?? void 0;
157
- const rawFreshness = readStringParam(args, "freshness");
158
- const freshness = normalizeExaFreshness(rawFreshness);
159
- if (rawFreshness && !freshness) return {
160
- error: "invalid_freshness",
161
- message: "freshness must be one of \"day\", \"week\", \"month\", or \"year\".",
162
- docs: "https://docs.openclaw.ai/tools/web"
163
- };
164
- const rawDateAfter = readStringParam(args, "date_after");
165
- const rawDateBefore = readStringParam(args, "date_before");
166
- if (freshness && (rawDateAfter || rawDateBefore)) return {
167
- error: "conflicting_time_filters",
168
- message: "freshness cannot be combined with date_after or date_before. Use one time-filter mode.",
169
- docs: "https://docs.openclaw.ai/tools/web"
170
- };
171
- const parsedDateRange = parseIsoDateRange({
172
- rawDateAfter,
173
- rawDateBefore,
174
- invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
175
- invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
176
- invalidDateRangeMessage: "date_after must be earlier than or equal to date_before."
177
- });
178
- if ("error" in parsedDateRange) return parsedDateRange;
179
- const { dateAfter, dateBefore } = parsedDateRange;
180
- const parsedContents = parseExaContents(args.contents);
181
- if ("error" in parsedContents) return parsedContents;
182
- const contents = parsedContents.value && Object.keys(parsedContents.value).length > 0 ? parsedContents.value : void 0;
183
- const resolvedCount = Math.min(EXA_MAX_SEARCH_COUNT, parseStrictPositiveInteger(count) ?? DEFAULT_SEARCH_COUNT);
184
- const cacheKey = buildSearchCacheKey([
185
- "exa",
186
- endpoint,
187
- type,
188
- query,
189
- resolvedCount,
190
- freshness,
191
- dateAfter,
192
- dateBefore,
193
- JSON.stringify(contents ?? { highlights: true })
194
- ]);
195
- const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
196
- const cached = readCachedSearchPayload(cacheKey, cacheTtlMs);
197
- if (cached) return cached;
198
- const start = Date.now();
199
- const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
200
- const body = {
201
- query,
202
- numResults: resolvedCount,
203
- type,
204
- contents: contents ?? { highlights: true }
205
- };
206
- if (dateAfter) body.startPublishedDate = dateAfter;
207
- else if (freshness) body.startPublishedDate = resolveFreshnessStartDate(freshness);
208
- if (dateBefore) body.endPublishedDate = dateBefore;
209
- const results = await withTrustedWebSearchEndpoint({
210
- url: endpoint,
211
- timeoutSeconds,
212
- signal,
213
- init: {
214
- method: "POST",
215
- headers: {
216
- Accept: "application/json",
217
- "Content-Type": "application/json",
218
- "x-api-key": apiKey,
219
- "x-exa-integration": "openclaw"
220
- },
221
- body: JSON.stringify(body)
222
- }
223
- }, async (res) => {
224
- if (!res.ok) {
225
- const detail = await readResponseTextLimited(res, EXA_ERROR_BODY_LIMIT_BYTES);
226
- throw new ProviderHttpError(`Exa API error (${res.status}): ${detail || res.statusText}`, { status: res.status });
227
- }
228
- return (await readExaSearchResults(res)).slice(0, resolvedCount);
229
- });
230
- signal?.throwIfAborted();
231
- const payload = {
232
- query,
233
- provider: "exa",
234
- count: results.length,
235
- tookMs: Date.now() - start,
236
- externalContent: {
237
- untrusted: true,
238
- source: "web_search",
239
- provider: "exa",
240
- wrapped: true
241
- },
242
- results: results.map((entry) => {
243
- const title = typeof entry.title === "string" ? entry.title : "";
244
- const url = typeof entry.url === "string" ? entry.url : "";
245
- const description = normalizeTrimmedStringList(entry.highlights).join("\n") || normalizeOptionalString(entry.summary) || normalizeOptionalString(entry.text) || "";
246
- const summary = normalizeOptionalString(entry.summary) ?? "";
247
- const highlightScores = Array.isArray(entry.highlightScores) ? entry.highlightScores.filter((score) => typeof score === "number" && Number.isFinite(score)) : [];
248
- const published = typeof entry.publishedDate === "string" && entry.publishedDate ? entry.publishedDate : void 0;
249
- return Object.assign({
250
- title: title ? wrapWebContent(title, `web_search`) : ``,
251
- url,
252
- description: description ? wrapWebContent(description, `web_search`) : ``,
253
- published,
254
- siteName: resolveSiteName(url) || void 0
255
- }, summary ? { summary: wrapWebContent(summary, `web_search`) } : {}, highlightScores.length > 0 ? { highlightScores } : {});
256
- })
257
- };
258
- writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
259
- return payload;
260
- }
261
- //#endregion
262
- export { executeExaWebSearchProviderTool };