重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createExaWebSearchProvider } from "./src/exa-web-search-provider.js";
export default definePluginEntry({
id: "exa",
name: "Exa Plugin",
description: "Bundled Exa web search plugin",
register(api) {
api.registerWebSearchProvider(createExaWebSearchProvider());
},
});

View file

@ -0,0 +1,32 @@
{
"id": "exa",
"providerAuthEnvVars": {
"exa": ["EXA_API_KEY"]
},
"uiHints": {
"webSearch.apiKey": {
"label": "Exa API Key",
"help": "Exa Search API key (fallback: EXA_API_KEY env var).",
"sensitive": true,
"placeholder": "exa-..."
}
},
"contracts": {
"webSearchProviders": ["exa"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
}
}
}
}
}
}

View file

@ -0,0 +1,15 @@
{
"name": "@openclaw/exa-plugin",
"version": "2026.4.20",
"private": true,
"description": "OpenClaw Exa plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -0,0 +1,527 @@
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
mergeScopedSearchConfig,
parseIsoDateRange,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchTimeoutSeconds,
resolveSiteName,
type SearchConfigRecord,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const;
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100;
type ExaConfig = {
apiKey?: string;
};
type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number];
type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number];
type ExaTextContentsOption = boolean | { maxCharacters?: number };
type ExaHighlightsContentsOption =
| boolean
| {
maxCharacters?: number;
query?: string;
numSentences?: number;
highlightsPerUrl?: number;
};
type ExaSummaryContentsOption = boolean | { query?: string };
type ExaContentsArgs = {
highlights?: ExaHighlightsContentsOption;
text?: ExaTextContentsOption;
summary?: ExaSummaryContentsOption;
};
type ExaSearchResult = {
title?: unknown;
url?: unknown;
publishedDate?: unknown;
highlights?: unknown;
highlightScores?: unknown;
summary?: unknown;
text?: unknown;
};
type ExaSearchResponse = {
results?: unknown;
};
function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined {
const trimmed = normalizeOptionalLowercaseString(value);
if (!trimmed) {
return undefined;
}
return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness)
? (trimmed as ExaFreshness)
: undefined;
}
function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig {
const exa = searchConfig?.exa;
return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {};
}
function resolveExaApiKey(exa?: ExaConfig): string | undefined {
return (
readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ??
readProviderEnvValue(["EXA_API_KEY"])
);
}
function resolveExaDescription(result: ExaSearchResult): string {
const highlights = result.highlights;
if (Array.isArray(highlights)) {
const highlightText = highlights
.map((entry) => normalizeOptionalString(entry))
.filter((entry): entry is string => Boolean(entry))
.join("\n");
if (highlightText) {
return highlightText;
}
}
const summary = normalizeOptionalString(result.summary);
if (summary) {
return summary;
}
return normalizeOptionalString(result.text) ?? "";
}
function parsePositiveInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
function invalidContentsPayload(message: string) {
return {
error: "invalid_contents",
message,
docs: "https://docs.openclaw.ai/tools/web",
};
}
function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } {
return Boolean(
value && typeof value === "object" && "error" in value && "message" in value && "docs" in value,
);
}
function resolveExaSearchCount(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed)));
}
function parseExaContents(
rawContents: unknown,
): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } {
if (rawContents === undefined) {
return { value: undefined };
}
if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) {
return invalidContentsPayload(
"contents must be an object with optional text, highlights, and summary fields.",
);
}
const raw = rawContents as Record<string, unknown>;
const allowedKeys = new Set(["text", "highlights", "summary"]);
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.`,
);
}
}
const parsed: ExaContentsArgs = {};
const parseText = (
value: unknown,
): ExaTextContentsOption | { error: string; message: string; docs: string } => {
if (typeof value === "boolean") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return invalidContentsPayload("contents.text must be a boolean or an object.");
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key !== "maxCharacters") {
return invalidContentsPayload(
`contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`,
);
}
}
if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
return invalidContentsPayload("contents.text.maxCharacters must be a positive integer.");
}
return parsePositiveInteger(obj.maxCharacters)
? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
: {};
};
const parseHighlights = (
value: unknown,
): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => {
if (typeof value === "boolean") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return invalidContentsPayload("contents.highlights must be a boolean or an object.");
}
const obj = value as Record<string, unknown>;
const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]);
for (const key of Object.keys(obj)) {
if (!allowed.has(key)) {
return invalidContentsPayload(
`contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`,
);
}
}
if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
return invalidContentsPayload(
"contents.highlights.maxCharacters must be a positive integer.",
);
}
if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) {
return invalidContentsPayload("contents.highlights.numSentences must be a positive integer.");
}
if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) {
return invalidContentsPayload(
"contents.highlights.highlightsPerUrl must be a positive integer.",
);
}
if ("query" in obj && typeof obj.query !== "string") {
return invalidContentsPayload("contents.highlights.query must be a string.");
}
return {
...(parsePositiveInteger(obj.maxCharacters)
? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
: {}),
...(typeof obj.query === "string" ? { query: obj.query } : {}),
...(parsePositiveInteger(obj.numSentences)
? { numSentences: parsePositiveInteger(obj.numSentences) }
: {}),
...(parsePositiveInteger(obj.highlightsPerUrl)
? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) }
: {}),
};
};
const parseSummary = (
value: unknown,
): ExaSummaryContentsOption | { error: string; message: string; docs: string } => {
if (typeof value === "boolean") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return invalidContentsPayload("contents.summary must be a boolean or an object.");
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key !== "query") {
return invalidContentsPayload(
`contents.summary has unknown field "${key}". Only "query" is allowed.`,
);
}
}
if ("query" in obj && typeof obj.query !== "string") {
return invalidContentsPayload("contents.summary.query must be a string.");
}
return typeof obj.query === "string" ? { query: obj.query } : {};
};
if ("text" in raw) {
const parsedText = parseText(raw.text);
if (isErrorPayload(parsedText)) {
return parsedText;
}
parsed.text = parsedText;
}
if ("highlights" in raw) {
const parsedHighlights = parseHighlights(raw.highlights);
if (isErrorPayload(parsedHighlights)) {
return parsedHighlights;
}
parsed.highlights = parsedHighlights;
}
if ("summary" in raw) {
const parsedSummary = parseSummary(raw.summary);
if (isErrorPayload(parsedSummary)) {
return parsedSummary;
}
parsed.summary = parsedSummary;
}
return { value: parsed };
}
function normalizeExaResults(payload: unknown): ExaSearchResult[] {
if (!payload || typeof payload !== "object") {
return [];
}
const results = (payload as ExaSearchResponse).results;
if (!Array.isArray(results)) {
return [];
}
return results.filter((entry): entry is ExaSearchResult =>
Boolean(entry && typeof entry === "object" && !Array.isArray(entry)),
);
}
function resolveFreshnessStartDate(freshness: ExaFreshness): string {
const now = new Date();
if (freshness === "day") {
now.setUTCDate(now.getUTCDate() - 1);
return now.toISOString();
}
if (freshness === "week") {
now.setUTCDate(now.getUTCDate() - 7);
return now.toISOString();
}
if (freshness === "month") {
const currentDay = now.getUTCDate();
now.setUTCDate(1);
now.setUTCMonth(now.getUTCMonth() - 1);
const lastDayOfTargetMonth = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0),
).getUTCDate();
now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth));
return now.toISOString();
}
now.setUTCFullYear(now.getUTCFullYear() - 1);
return now.toISOString();
}
async function runExaSearch(params: {
apiKey: string;
query: string;
count: number;
freshness?: ExaFreshness;
dateAfter?: string;
dateBefore?: string;
type: ExaSearchType;
contents?: ExaContentsArgs;
timeoutSeconds: number;
}): Promise<ExaSearchResult[]> {
const body: Record<string, unknown> = {
query: params.query,
numResults: params.count,
type: params.type,
contents: params.contents ?? { highlights: true },
};
if (params.dateAfter) {
body.startPublishedDate = params.dateAfter;
} else if (params.freshness) {
body.startPublishedDate = resolveFreshnessStartDate(params.freshness);
}
if (params.dateBefore) {
body.endPublishedDate = params.dateBefore;
}
return withTrustedWebSearchEndpoint(
{
url: EXA_SEARCH_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"x-api-key": params.apiKey,
"x-exa-integration": "openclaw",
},
body: JSON.stringify(body),
},
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
}
try {
return normalizeExaResults(await res.json());
} catch (error) {
throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error });
}
},
);
}
function missingExaKeyPayload() {
return {
error: "missing_exa_api_key",
message:
"web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
export async function executeExaWebSearchProviderTool(
ctx: { config?: Record<string, unknown>; searchConfig?: SearchConfigRecord },
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const searchConfig = mergeScopedSearchConfig(
ctx.searchConfig,
"exa",
resolveProviderWebSearchPluginConfig(ctx.config, "exa"),
) as SearchConfigRecord | undefined;
const params = args;
const exaConfig = resolveExaConfig(searchConfig);
const apiKey = resolveExaApiKey(exaConfig);
if (!apiKey) {
return missingExaKeyPayload();
}
const query = readStringParam(params, "query", { required: true });
const rawType = readStringParam(params, "type");
const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType)
? (rawType as ExaSearchType)
: "auto";
const count =
readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
const rawFreshness = readStringParam(params, "freshness");
const freshness = normalizeExaFreshness(rawFreshness);
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: 'freshness must be one of "day", "week", "month", or "year".',
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawDateAfter = readStringParam(params, "date_after");
const rawDateBefore = readStringParam(params, "date_before");
if (freshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness cannot be combined with date_after or date_before. Use one time-filter mode.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const parsedDateRange = parseIsoDateRange({
rawDateAfter,
rawDateBefore,
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.",
});
if ("error" in parsedDateRange) {
return parsedDateRange;
}
const { dateAfter, dateBefore } = parsedDateRange;
const parsedContents = parseExaContents(params.contents);
if (isErrorPayload(parsedContents)) {
return parsedContents;
}
const contents =
parsedContents.value && Object.keys(parsedContents.value).length > 0
? parsedContents.value
: undefined;
const cacheKey = buildSearchCacheKey([
"exa",
type,
query,
resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
freshness,
dateAfter,
dateBefore,
contents?.highlights ? JSON.stringify(contents.highlights) : undefined,
contents?.text ? JSON.stringify(contents.text) : undefined,
contents?.summary ? JSON.stringify(contents.summary) : undefined,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const results = await runExaSearch({
apiKey,
query,
count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
freshness,
dateAfter,
dateBefore,
type,
contents,
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
});
const payload = {
query,
provider: "exa",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "exa",
wrapped: true,
},
results: results.map((entry) => {
const title = typeof entry.title === "string" ? entry.title : "";
const url = typeof entry.url === "string" ? entry.url : "";
const description = resolveExaDescription(entry);
const summary = normalizeOptionalString(entry.summary) ?? "";
const highlightScores = Array.isArray(entry.highlightScores)
? entry.highlightScores.filter(
(score): score is number => typeof score === "number" && Number.isFinite(score),
)
: [];
const published =
typeof entry.publishedDate === "string" && entry.publishedDate
? entry.publishedDate
: undefined;
return Object.assign(
{
title: title ? wrapWebContent(title, `web_search`) : ``,
url,
description: description ? wrapWebContent(description, `web_search`) : ``,
published,
siteName: resolveSiteName(url) || undefined,
},
summary ? { summary: wrapWebContent(summary, `web_search`) } : {},
highlightScores.length > 0 ? { highlightScores } : {},
);
}),
};
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
}
export const __testing = {
normalizeExaResults,
normalizeExaFreshness,
parseExaContents,
resolveExaApiKey,
resolveExaConfig,
resolveExaDescription,
resolveExaSearchCount,
resolveFreshnessStartDate,
} as const;

View file

@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import { __testing } from "../test-api.js";
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
import { createExaWebSearchProvider } from "./exa-web-search-provider.js";
describe("exa web search provider", () => {
it("exposes the expected metadata and selection wiring", () => {
const provider = createExaWebSearchProvider();
if (!provider.applySelectionConfig) {
throw new Error("Expected applySelectionConfig to be defined");
}
const applied = provider.applySelectionConfig({});
expect(provider.id).toBe("exa");
expect(provider.onboardingScopes).toEqual(["text-inference"]);
expect(provider.credentialPath).toBe("plugins.entries.exa.config.webSearch.apiKey");
expect(applied.plugins?.entries?.exa?.enabled).toBe(true);
});
it("keeps the lightweight contract surface aligned with provider metadata", () => {
const provider = createExaWebSearchProvider();
const contractProvider = createContractExaWebSearchProvider();
if (!contractProvider.applySelectionConfig) {
throw new Error("Expected contract applySelectionConfig to be defined");
}
const applied = contractProvider.applySelectionConfig({});
expect(contractProvider).toMatchObject({
id: provider.id,
label: provider.label,
hint: provider.hint,
onboardingScopes: provider.onboardingScopes,
credentialLabel: provider.credentialLabel,
envVars: provider.envVars,
placeholder: provider.placeholder,
signupUrl: provider.signupUrl,
docsUrl: provider.docsUrl,
autoDetectOrder: provider.autoDetectOrder,
credentialPath: provider.credentialPath,
});
expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull();
expect(applied.plugins?.entries?.exa?.enabled).toBe(true);
});
it("prefers scoped configured api keys over environment fallbacks", () => {
expect(__testing.resolveExaApiKey({ apiKey: "exa-secret" })).toBe("exa-secret");
});
it("normalizes Exa result descriptions from highlights before text", () => {
expect(
__testing.resolveExaDescription({
highlights: ["first", "", "second"],
text: "full text",
}),
).toBe("first\nsecond");
expect(__testing.resolveExaDescription({ text: "full text" })).toBe("full text");
});
it("handles month freshness without date overflow", () => {
const iso = __testing.resolveFreshnessStartDate("month");
expect(Number.isNaN(Date.parse(iso))).toBe(false);
});
it("accepts current Exa contents object options from the docs", () => {
expect(
__testing.parseExaContents({
text: { maxCharacters: 1200 },
highlights: {
maxCharacters: 4000,
query: "latest model launches",
numSentences: 4,
highlightsPerUrl: 2,
},
summary: { query: "launch details" },
}),
).toEqual({
value: {
text: { maxCharacters: 1200 },
highlights: {
maxCharacters: 4000,
query: "latest model launches",
numSentences: 4,
highlightsPerUrl: 2,
},
summary: { query: "launch details" },
},
});
});
it("rejects invalid Exa contents objects", () => {
expect(
__testing.parseExaContents({
highlights: { numSentences: 0 },
}),
).toMatchObject({
error: "invalid_contents",
});
});
it("exposes newer documented Exa search types and count limits", () => {
const provider = createExaWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: { exa: { apiKey: "exa-secret" } },
});
if (!tool) {
throw new Error("Expected tool definition");
}
const parameters = tool.parameters as {
properties?: {
count?: { maximum?: number };
type?: { enum?: string[] };
};
};
expect(parameters.properties?.count?.maximum).toBe(100);
expect(parameters.properties?.type?.enum).toEqual([
"auto",
"neural",
"fast",
"deep",
"deep-reasoning",
"instant",
]);
expect(__testing.resolveExaSearchCount(80, 10)).toBe(80);
expect(__testing.resolveExaSearchCount(120, 10)).toBe(100);
});
it("returns validation errors for conflicting time filters", async () => {
const provider = createExaWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: { exa: { apiKey: "exa-secret" } },
});
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
query: "latest gpu news",
freshness: "day",
date_after: "2026-03-01",
});
expect(result).toMatchObject({
error: "conflicting_time_filters",
});
});
it("returns validation errors for invalid date input", async () => {
const provider = createExaWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: { exa: { apiKey: "exa-secret" } },
});
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
query: "latest gpu news",
date_after: "2026-02-31",
});
expect(result).toMatchObject({
error: "invalid_date",
});
});
});

View file

@ -0,0 +1,98 @@
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract";
const EXA_CREDENTIAL_PATH = "plugins.entries.exa.config.webSearch.apiKey";
const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const;
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100;
type ExaWebSearchRuntime = typeof import("./exa-web-search-provider.runtime.js");
let exaWebSearchRuntimePromise: Promise<ExaWebSearchRuntime> | undefined;
function loadExaWebSearchRuntime(): Promise<ExaWebSearchRuntime> {
exaWebSearchRuntimePromise ??= import("./exa-web-search-provider.runtime.js");
return exaWebSearchRuntimePromise;
}
const ExaSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-100, subject to Exa search-type limits).",
minimum: 1,
maximum: EXA_MAX_SEARCH_COUNT,
},
freshness: {
type: "string",
enum: [...EXA_FRESHNESS_VALUES],
description: 'Filter by time: "day", "week", "month", or "year".',
},
date_after: {
type: "string",
description: "Only results published after this date (YYYY-MM-DD).",
},
date_before: {
type: "string",
description: "Only results published before this date (YYYY-MM-DD).",
},
type: {
type: "string",
enum: [...EXA_SEARCH_TYPES],
description:
'Exa search mode: "auto", "neural", "fast", "deep", "deep-reasoning", or "instant".',
},
contents: {
type: "object",
properties: {
highlights: {
description:
"Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.",
},
text: {
description: "Text config: true, or an object with maxCharacters.",
},
summary: {
description: "Summary config: true, or an object with query.",
},
},
additionalProperties: false,
},
},
additionalProperties: false,
} satisfies Record<string, unknown>;
export function createExaWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "exa",
label: "Exa Search",
hint: "Neural + keyword search with date filters and content extraction",
onboardingScopes: ["text-inference"],
credentialLabel: "Exa API key",
envVars: ["EXA_API_KEY"],
placeholder: "exa-...",
signupUrl: "https://exa.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 65,
credentialPath: EXA_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: EXA_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "exa" },
configuredCredential: { pluginId: "exa" },
selectionPluginId: "exa",
}),
createTool: (ctx) => ({
description:
"Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.",
parameters: ExaSearchSchema,
execute: async (args) => {
const { executeExaWebSearchProviderTool } = await loadExaWebSearchRuntime();
return await executeExaWebSearchProviderTool(ctx, args);
},
}),
};
}

View file

@ -0,0 +1 @@
export { __testing } from "./src/exa-web-search-provider.runtime.js";

View file

@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View file

@ -0,0 +1,29 @@
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract";
export function createExaWebSearchProvider(): WebSearchProviderPlugin {
const credentialPath = "plugins.entries.exa.config.webSearch.apiKey";
return {
id: "exa",
label: "Exa Search",
hint: "Neural + keyword search with date filters and content extraction",
onboardingScopes: ["text-inference"],
credentialLabel: "Exa API key",
envVars: ["EXA_API_KEY"],
placeholder: "exa-...",
signupUrl: "https://exa.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 65,
credentialPath,
...createWebSearchProviderContractFields({
credentialPath,
searchCredential: { type: "scoped", scopeId: "exa" },
configuredCredential: { pluginId: "exa" },
selectionPluginId: "exa",
}),
createTool: () => null,
};
}

View file

@ -0,0 +1 @@
export { createExaWebSearchProvider } from "./src/exa-web-search-provider.js";