@ever-works/exa-plugin 1.0.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/LICENSE +661 -0
- package/README.md +78 -0
- package/package.json +78 -0
- package/src/__tests__/exa.plugin.spec.ts +313 -0
- package/src/exa.plugin.ts +406 -0
- package/src/index.ts +2 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IPlugin,
|
|
3
|
+
ISearchPlugin,
|
|
4
|
+
IContentExtractorPlugin,
|
|
5
|
+
PluginContext,
|
|
6
|
+
PluginCategory,
|
|
7
|
+
PluginManifest,
|
|
8
|
+
PluginHealthCheck,
|
|
9
|
+
JsonSchema,
|
|
10
|
+
PluginSettings,
|
|
11
|
+
SearchOptions,
|
|
12
|
+
SearchResponse,
|
|
13
|
+
SearchResult,
|
|
14
|
+
RateLimitInfo,
|
|
15
|
+
ContentExtractionOptions,
|
|
16
|
+
ContentExtractionResult,
|
|
17
|
+
ConnectionValidationResult
|
|
18
|
+
} from '@ever-works/plugin';
|
|
19
|
+
|
|
20
|
+
import { Exa } from 'exa-js';
|
|
21
|
+
|
|
22
|
+
// Security (SSRF): lexical guard that rejects non-HTTP(S) schemes and literal
|
|
23
|
+
// private/loopback/link-local/cloud-metadata IPs. Imported directly from the
|
|
24
|
+
// helper subpath because the plugin barrel intentionally does not re-export it
|
|
25
|
+
// (it pulls in node:net / node:dns).
|
|
26
|
+
import { isSafeWebhookUrl } from '@ever-works/plugin/helpers/ssrf-guard';
|
|
27
|
+
|
|
28
|
+
const SEARCH_TYPES = ['auto', 'neural', 'keyword'] as const;
|
|
29
|
+
|
|
30
|
+
const CATEGORIES = ['', 'company', 'research paper', 'news', 'tweet', 'personal site', 'github'] as const;
|
|
31
|
+
|
|
32
|
+
const TIME_RANGE_DAYS: Record<string, number> = {
|
|
33
|
+
day: 1,
|
|
34
|
+
week: 7,
|
|
35
|
+
month: 30,
|
|
36
|
+
year: 365
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const API_KEY_ERROR =
|
|
40
|
+
'Exa API key not configured. Set it in plugin settings or via PLUGIN_EXA_API_KEY environment variable.';
|
|
41
|
+
|
|
42
|
+
export class ExaSearchPlugin implements IPlugin, ISearchPlugin, IContentExtractorPlugin {
|
|
43
|
+
readonly id = 'exa';
|
|
44
|
+
readonly name = 'Exa';
|
|
45
|
+
readonly version = '1.0.0';
|
|
46
|
+
readonly category: PluginCategory = 'search';
|
|
47
|
+
readonly capabilities: readonly string[] = ['search', 'content-extractor'];
|
|
48
|
+
readonly providerName = 'Exa';
|
|
49
|
+
|
|
50
|
+
readonly settingsSchema: JsonSchema = {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
apiKey: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
title: 'API Key',
|
|
56
|
+
description: 'Your Exa API key. Get one at https://exa.ai',
|
|
57
|
+
'x-secret': true,
|
|
58
|
+
'x-envVar': 'PLUGIN_EXA_API_KEY',
|
|
59
|
+
'x-scope': 'user'
|
|
60
|
+
},
|
|
61
|
+
searchType: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
title: 'Search Type',
|
|
64
|
+
description: 'Search mode: auto (recommended), neural (semantic), or keyword (traditional)',
|
|
65
|
+
enum: [...SEARCH_TYPES],
|
|
66
|
+
default: 'auto'
|
|
67
|
+
},
|
|
68
|
+
maxResults: {
|
|
69
|
+
type: 'number',
|
|
70
|
+
title: 'Default Max Results',
|
|
71
|
+
description: 'Default maximum number of results per search',
|
|
72
|
+
default: 10,
|
|
73
|
+
minimum: 1,
|
|
74
|
+
maximum: 100
|
|
75
|
+
},
|
|
76
|
+
category: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
title: 'Category Filter',
|
|
79
|
+
description: 'Optionally restrict results to a specific category',
|
|
80
|
+
enum: [...CATEGORIES],
|
|
81
|
+
default: ''
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
required: ['apiKey']
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
readonly configurationMode: 'admin-only' | 'user-required' | 'hybrid' = 'hybrid';
|
|
88
|
+
|
|
89
|
+
private context?: PluginContext;
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// ISearchPlugin Interface
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
async search(options: SearchOptions): Promise<SearchResponse> {
|
|
96
|
+
const client = this.getClient(options.settings);
|
|
97
|
+
const startTime = Date.now();
|
|
98
|
+
const searchType = (options.settings?.searchType as string) || 'auto';
|
|
99
|
+
const limit = options.limit || (options.settings?.maxResults as number) || 10;
|
|
100
|
+
const category = (options.settings?.category as string) || '';
|
|
101
|
+
|
|
102
|
+
const searchOptions: Record<string, unknown> = {
|
|
103
|
+
numResults: limit,
|
|
104
|
+
type: searchType
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (category) {
|
|
108
|
+
searchOptions.category = category;
|
|
109
|
+
}
|
|
110
|
+
if (options.includeDomains && options.includeDomains.length > 0) {
|
|
111
|
+
searchOptions.includeDomains = [...options.includeDomains];
|
|
112
|
+
}
|
|
113
|
+
if (options.excludeDomains && options.excludeDomains.length > 0) {
|
|
114
|
+
searchOptions.excludeDomains = [...options.excludeDomains];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (options.timeRange && options.timeRange !== 'all') {
|
|
118
|
+
const days = TIME_RANGE_DAYS[options.timeRange];
|
|
119
|
+
if (days) {
|
|
120
|
+
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
121
|
+
searchOptions.startPublishedDate = startDate.toISOString();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const response = await client.search(options.query, searchOptions);
|
|
127
|
+
|
|
128
|
+
const results: SearchResult[] = response.results.map((r, index) => ({
|
|
129
|
+
title: r.title || '',
|
|
130
|
+
url: r.url || '',
|
|
131
|
+
publishedDate: r.publishedDate,
|
|
132
|
+
source: r.author,
|
|
133
|
+
faviconUrl: r.favicon,
|
|
134
|
+
position: index + 1
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
results,
|
|
139
|
+
query: options.query,
|
|
140
|
+
totalResults: results.length,
|
|
141
|
+
hasMore: false,
|
|
142
|
+
duration: Date.now() - startTime
|
|
143
|
+
};
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.context?.logger.error(`Exa failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async isAvailable(): Promise<boolean> {
|
|
151
|
+
if (!this.context) return false;
|
|
152
|
+
const settings = await this.context.getSettings();
|
|
153
|
+
return Boolean(settings?.apiKey);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async validateConnection(settings: Record<string, unknown>): Promise<ConnectionValidationResult> {
|
|
157
|
+
const apiKey = settings.apiKey as string | undefined;
|
|
158
|
+
if (!apiKey) {
|
|
159
|
+
return { success: false, message: 'Exa API key is not configured.' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const client = this.getClient(settings);
|
|
164
|
+
await client.search('test', { numResults: 1 });
|
|
165
|
+
return { success: true, message: 'Exa connection verified.' };
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return {
|
|
168
|
+
success: false,
|
|
169
|
+
message: `Exa connection failed: ${error instanceof Error ? error.message : String(error)}`
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async getRateLimitInfo(): Promise<RateLimitInfo> {
|
|
175
|
+
return {
|
|
176
|
+
remaining: -1,
|
|
177
|
+
limit: -1,
|
|
178
|
+
period: 'month'
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ============================================================================
|
|
183
|
+
// IContentExtractorPlugin Interface
|
|
184
|
+
// ============================================================================
|
|
185
|
+
|
|
186
|
+
async extract(options: ContentExtractionOptions): Promise<ContentExtractionResult> {
|
|
187
|
+
const startTime = Date.now();
|
|
188
|
+
|
|
189
|
+
// Security (SSRF): `extract()` relies on the caller invoking `canExtract()`
|
|
190
|
+
// first, which only validates the scheme and cannot be guaranteed.
|
|
191
|
+
// `options.url` is attacker-controllable (every item's source_url, including
|
|
192
|
+
// URLs the community-PR LLM picks) and is forwarded to the Exa API to fetch.
|
|
193
|
+
// Refuse non-HTTP(S) schemes and literal private/loopback/link-local/
|
|
194
|
+
// cloud-metadata targets (e.g. http://169.254.169.254/...) before issuing the
|
|
195
|
+
// request. Mirrors local-content-extractor / pdf-extractor / scrapfly.
|
|
196
|
+
// Legitimate https URLs pass.
|
|
197
|
+
if (!isSafeWebhookUrl(options.url)) {
|
|
198
|
+
return {
|
|
199
|
+
success: false,
|
|
200
|
+
url: options.url,
|
|
201
|
+
error: `URL host is not safe to extract (SSRF guard blocked: ${options.url})`,
|
|
202
|
+
duration: Date.now() - startTime
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const client = this.getClient(options.settings);
|
|
208
|
+
const response = await client.getContents([options.url], { text: true, livecrawl: 'fallback' });
|
|
209
|
+
|
|
210
|
+
if (!response.results || response.results.length === 0) {
|
|
211
|
+
return {
|
|
212
|
+
success: false,
|
|
213
|
+
url: options.url,
|
|
214
|
+
error: 'No content extracted',
|
|
215
|
+
duration: Date.now() - startTime
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const result = response.results[0];
|
|
220
|
+
const text = result.text || '';
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
success: true,
|
|
224
|
+
url: options.url,
|
|
225
|
+
finalUrl: result.url !== options.url ? result.url : undefined,
|
|
226
|
+
content: text,
|
|
227
|
+
title: result.title || undefined,
|
|
228
|
+
duration: Date.now() - startTime,
|
|
229
|
+
wordCount: text ? text.split(/\s+/).length : 0
|
|
230
|
+
};
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return {
|
|
233
|
+
success: false,
|
|
234
|
+
url: options.url,
|
|
235
|
+
error: error instanceof Error ? error.message : String(error),
|
|
236
|
+
duration: Date.now() - startTime
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async extractBatch(
|
|
242
|
+
urls: readonly string[],
|
|
243
|
+
options?: Partial<ContentExtractionOptions>
|
|
244
|
+
): Promise<readonly ContentExtractionResult[]> {
|
|
245
|
+
const startTime = Date.now();
|
|
246
|
+
|
|
247
|
+
// Security (SSRF): every URL is attacker-controllable and is forwarded to the
|
|
248
|
+
// Exa API to fetch. `canExtract()` (scheme-only) is not guaranteed to run, so
|
|
249
|
+
// guard each URL here. Partition out non-HTTP(S) schemes and literal private/
|
|
250
|
+
// loopback/link-local/cloud-metadata targets; blocked URLs get a per-index
|
|
251
|
+
// failure result and are never sent upstream, while safe URLs proceed. Result
|
|
252
|
+
// order stays aligned with the input `urls`. All-legitimate batches behave
|
|
253
|
+
// exactly as before. Mirrors local-content-extractor / pdf-extractor / scrapfly.
|
|
254
|
+
const results: ContentExtractionResult[] = new Array(urls.length);
|
|
255
|
+
const safeUrls: string[] = [];
|
|
256
|
+
const safeIndexes: number[] = [];
|
|
257
|
+
urls.forEach((url, index) => {
|
|
258
|
+
if (isSafeWebhookUrl(url)) {
|
|
259
|
+
safeUrls.push(url);
|
|
260
|
+
safeIndexes.push(index);
|
|
261
|
+
} else {
|
|
262
|
+
results[index] = {
|
|
263
|
+
success: false,
|
|
264
|
+
url,
|
|
265
|
+
error: `URL host is not safe to extract (SSRF guard blocked: ${url})`,
|
|
266
|
+
duration: Date.now() - startTime
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (safeUrls.length === 0) {
|
|
272
|
+
return results;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const client = this.getClient(options?.settings);
|
|
277
|
+
const response = await client.getContents([...safeUrls], { text: true, livecrawl: 'fallback' });
|
|
278
|
+
|
|
279
|
+
response.results.forEach((result, i) => {
|
|
280
|
+
const originalIndex = safeIndexes[i];
|
|
281
|
+
const text = result.text || '';
|
|
282
|
+
const requestedUrl = safeUrls[i] || result.url;
|
|
283
|
+
results[originalIndex] = {
|
|
284
|
+
success: true,
|
|
285
|
+
url: requestedUrl,
|
|
286
|
+
finalUrl: result.url !== requestedUrl ? result.url : undefined,
|
|
287
|
+
content: text,
|
|
288
|
+
title: result.title || undefined,
|
|
289
|
+
duration: Date.now() - startTime,
|
|
290
|
+
wordCount: text ? text.split(/\s+/).length : 0
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Fill any safe URLs the API did not return a result for so the output
|
|
295
|
+
// array has no holes (preserves the original positional contract).
|
|
296
|
+
safeIndexes.forEach((originalIndex, i) => {
|
|
297
|
+
if (!results[originalIndex]) {
|
|
298
|
+
results[originalIndex] = {
|
|
299
|
+
success: false,
|
|
300
|
+
url: safeUrls[i],
|
|
301
|
+
error: 'No content extracted',
|
|
302
|
+
duration: Date.now() - startTime
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
return results;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
safeIndexes.forEach((originalIndex, i) => {
|
|
310
|
+
results[originalIndex] = {
|
|
311
|
+
success: false,
|
|
312
|
+
url: safeUrls[i],
|
|
313
|
+
error: error instanceof Error ? error.message : String(error),
|
|
314
|
+
duration: Date.now() - startTime
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
return results;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async canExtract(url: string): Promise<boolean> {
|
|
322
|
+
try {
|
|
323
|
+
const parsed = new URL(url);
|
|
324
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
325
|
+
} catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
getSupportedFormats(): readonly ('text' | 'html' | 'markdown')[] {
|
|
331
|
+
return ['text'];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ============================================================================
|
|
335
|
+
// IPlugin Lifecycle
|
|
336
|
+
// ============================================================================
|
|
337
|
+
|
|
338
|
+
private getClient(settings?: PluginSettings): Exa {
|
|
339
|
+
const apiKey = settings?.apiKey as string;
|
|
340
|
+
if (!apiKey) {
|
|
341
|
+
throw new Error(API_KEY_ERROR);
|
|
342
|
+
}
|
|
343
|
+
return new Exa(apiKey);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async onLoad(context: PluginContext): Promise<void> {
|
|
347
|
+
this.context = context;
|
|
348
|
+
context.logger.log('Exa Plugin loaded');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async onUnload(): Promise<void> {
|
|
352
|
+
this.context = undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async healthCheck(): Promise<PluginHealthCheck> {
|
|
356
|
+
return {
|
|
357
|
+
status: 'healthy',
|
|
358
|
+
message: 'Exa plugin is ready (API key required for operations)',
|
|
359
|
+
checkedAt: Date.now()
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
getManifest(): PluginManifest {
|
|
364
|
+
return {
|
|
365
|
+
id: this.id,
|
|
366
|
+
name: this.name,
|
|
367
|
+
version: this.version,
|
|
368
|
+
description: 'AI-native search and content extraction using the Exa API',
|
|
369
|
+
category: this.category,
|
|
370
|
+
capabilities: [...this.capabilities],
|
|
371
|
+
author: { name: 'Ever Works Team' },
|
|
372
|
+
license: 'AGPL-3.0',
|
|
373
|
+
builtIn: true,
|
|
374
|
+
systemPlugin: false,
|
|
375
|
+
autoEnable: false,
|
|
376
|
+
readme: [
|
|
377
|
+
'## What does Exa do?',
|
|
378
|
+
'',
|
|
379
|
+
'Exa is an AI-native search engine that understands meaning, not just keywords. It offers neural search (semantic understanding), keyword search (traditional matching), and an auto mode that picks the best approach for each query. It can also extract clean text content from web pages.',
|
|
380
|
+
'',
|
|
381
|
+
'## Why use it?',
|
|
382
|
+
'',
|
|
383
|
+
'- **Neural search** — finds results based on meaning, not just keyword matching',
|
|
384
|
+
'- **Content extraction** — pulls clean text from any web page URL',
|
|
385
|
+
'- **Category filtering** — restrict to companies, research papers, news, tweets, GitHub repos, or personal sites',
|
|
386
|
+
'- **Domain control** — include or exclude specific domains from results',
|
|
387
|
+
'- **Time filtering** — find results from the last day, week, month, or year',
|
|
388
|
+
'',
|
|
389
|
+
'## How it works in Ever Works',
|
|
390
|
+
'',
|
|
391
|
+
'When enabled and set as the active search provider, Exa is used during work generation to find information about each item. Its neural search mode is particularly useful for finding semantically relevant content that keyword-based engines might miss. The content extraction capability can pull text from web pages for enriching work items.',
|
|
392
|
+
'',
|
|
393
|
+
'## Getting started',
|
|
394
|
+
'',
|
|
395
|
+
'1. Create an account at [exa.ai](https://exa.ai)',
|
|
396
|
+
'2. Copy your API key from the Exa dashboard',
|
|
397
|
+
'3. Enter the key in the **API Key** field below',
|
|
398
|
+
'4. Choose your preferred search type (auto recommended)',
|
|
399
|
+
'5. Enable this plugin to use it for work generation'
|
|
400
|
+
].join('\n'),
|
|
401
|
+
homepage: 'https://exa.ai'
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export default ExaSearchPlugin;
|
package/src/index.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"types": ["node"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "./src",
|
|
12
|
+
"strict": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true,
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"isolatedModules": true,
|
|
18
|
+
"noEmit": true
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*"],
|
|
21
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
|
22
|
+
}
|
package/tsup.config.ts
ADDED