@ever-works/exa-plugin 1.0.0 → 1.0.3

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/dist/index.cjs ADDED
@@ -0,0 +1,390 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var exaJs = require('exa-js');
6
+ var net = require('net');
7
+
8
+ // src/exa.plugin.ts
9
+ function isSafeWebhookUrl(rawUrl) {
10
+ let url;
11
+ try {
12
+ url = new URL(rawUrl);
13
+ } catch {
14
+ return false;
15
+ }
16
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
17
+ return false;
18
+ }
19
+ let host = url.hostname.toLowerCase();
20
+ if (host.startsWith("[") && host.endsWith("]")) {
21
+ host = host.slice(1, -1);
22
+ }
23
+ if (CLOUD_METADATA_HOSTNAMES.has(host)) {
24
+ return false;
25
+ }
26
+ const ipKind = net.isIP(host);
27
+ if (ipKind === 4 && isPrivateIPv4(host)) return false;
28
+ if (ipKind === 6 && isPrivateIPv6(host)) return false;
29
+ return true;
30
+ }
31
+ var CLOUD_METADATA_HOSTNAMES = /* @__PURE__ */ new Set([
32
+ // GCP
33
+ "metadata.google.internal",
34
+ "metadata.goog",
35
+ // AWS
36
+ "instance-data",
37
+ "instance-data.ec2.internal",
38
+ // Azure
39
+ "metadata.azure.internal"
40
+ ]);
41
+ function isPrivateIPv4(ip) {
42
+ const parts = ip.split(".").map((p) => Number(p));
43
+ if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
44
+ const [a, b] = parts;
45
+ if (a === 10) return true;
46
+ if (a === 127) return true;
47
+ if (a === 0) return true;
48
+ if (a === 169 && b === 254) return true;
49
+ if (a === 172 && b >= 16 && b <= 31) return true;
50
+ if (a === 192 && b === 168) return true;
51
+ if (a === 192 && b === 0 && parts[2] === 0) return true;
52
+ if (a === 100 && b >= 64 && b <= 127) return true;
53
+ if (a >= 224) return true;
54
+ return false;
55
+ }
56
+ function isPrivateIPv6(ip) {
57
+ const lower = ip.toLowerCase();
58
+ if (lower === "::1" || lower === "::") return true;
59
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
60
+ const firstHextet = lower.split(":", 1)[0];
61
+ if (/^[0-9a-f]{1,4}$/.test(firstHextet)) {
62
+ const group0 = parseInt(firstHextet, 16);
63
+ if ((group0 & 65472) === 65152) return true;
64
+ }
65
+ if (lower.startsWith("::ffff:")) {
66
+ const v4 = lower.slice("::ffff:".length);
67
+ if (net.isIP(v4) === 4) return isPrivateIPv4(v4);
68
+ }
69
+ return false;
70
+ }
71
+
72
+ // src/exa.plugin.ts
73
+ var SEARCH_TYPES = ["auto", "neural", "keyword"];
74
+ var CATEGORIES = ["", "company", "research paper", "news", "tweet", "personal site", "github"];
75
+ var TIME_RANGE_DAYS = {
76
+ day: 1,
77
+ week: 7,
78
+ month: 30,
79
+ year: 365
80
+ };
81
+ var API_KEY_ERROR = "Exa API key not configured. Set it in plugin settings or via PLUGIN_EXA_API_KEY environment variable.";
82
+ var ExaSearchPlugin = class {
83
+ id = "exa";
84
+ name = "Exa";
85
+ version = "1.0.0";
86
+ category = "search";
87
+ capabilities = ["search", "content-extractor"];
88
+ providerName = "Exa";
89
+ settingsSchema = {
90
+ type: "object",
91
+ properties: {
92
+ apiKey: {
93
+ type: "string",
94
+ title: "API Key",
95
+ description: "Your Exa API key. Get one at https://exa.ai",
96
+ "x-secret": true,
97
+ "x-envVar": "PLUGIN_EXA_API_KEY",
98
+ "x-scope": "user"
99
+ },
100
+ searchType: {
101
+ type: "string",
102
+ title: "Search Type",
103
+ description: "Search mode: auto (recommended), neural (semantic), or keyword (traditional)",
104
+ enum: [...SEARCH_TYPES],
105
+ default: "auto"
106
+ },
107
+ maxResults: {
108
+ type: "number",
109
+ title: "Default Max Results",
110
+ description: "Default maximum number of results per search",
111
+ default: 10,
112
+ minimum: 1,
113
+ maximum: 100
114
+ },
115
+ category: {
116
+ type: "string",
117
+ title: "Category Filter",
118
+ description: "Optionally restrict results to a specific category",
119
+ enum: [...CATEGORIES],
120
+ default: ""
121
+ }
122
+ },
123
+ required: ["apiKey"]
124
+ };
125
+ configurationMode = "hybrid";
126
+ context;
127
+ // ============================================================================
128
+ // ISearchPlugin Interface
129
+ // ============================================================================
130
+ async search(options) {
131
+ const client = this.getClient(options.settings);
132
+ const startTime = Date.now();
133
+ const searchType = options.settings?.searchType || "auto";
134
+ const limit = options.limit || options.settings?.maxResults || 10;
135
+ const category = options.settings?.category || "";
136
+ const searchOptions = {
137
+ numResults: limit,
138
+ type: searchType
139
+ };
140
+ if (category) {
141
+ searchOptions.category = category;
142
+ }
143
+ if (options.includeDomains && options.includeDomains.length > 0) {
144
+ searchOptions.includeDomains = [...options.includeDomains];
145
+ }
146
+ if (options.excludeDomains && options.excludeDomains.length > 0) {
147
+ searchOptions.excludeDomains = [...options.excludeDomains];
148
+ }
149
+ if (options.timeRange && options.timeRange !== "all") {
150
+ const days = TIME_RANGE_DAYS[options.timeRange];
151
+ if (days) {
152
+ const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
153
+ searchOptions.startPublishedDate = startDate.toISOString();
154
+ }
155
+ }
156
+ try {
157
+ const response = await client.search(options.query, searchOptions);
158
+ const results = response.results.map((r, index) => ({
159
+ title: r.title || "",
160
+ url: r.url || "",
161
+ publishedDate: r.publishedDate,
162
+ source: r.author,
163
+ faviconUrl: r.favicon,
164
+ position: index + 1
165
+ }));
166
+ return {
167
+ results,
168
+ query: options.query,
169
+ totalResults: results.length,
170
+ hasMore: false,
171
+ duration: Date.now() - startTime
172
+ };
173
+ } catch (error) {
174
+ this.context?.logger.error(`Exa failed: ${error instanceof Error ? error.message : String(error)}`);
175
+ throw error;
176
+ }
177
+ }
178
+ async isAvailable() {
179
+ if (!this.context) return false;
180
+ const settings = await this.context.getSettings();
181
+ return Boolean(settings?.apiKey);
182
+ }
183
+ async validateConnection(settings) {
184
+ const apiKey = settings.apiKey;
185
+ if (!apiKey) {
186
+ return { success: false, message: "Exa API key is not configured." };
187
+ }
188
+ try {
189
+ const client = this.getClient(settings);
190
+ await client.search("test", { numResults: 1 });
191
+ return { success: true, message: "Exa connection verified." };
192
+ } catch (error) {
193
+ return {
194
+ success: false,
195
+ message: `Exa connection failed: ${error instanceof Error ? error.message : String(error)}`
196
+ };
197
+ }
198
+ }
199
+ async getRateLimitInfo() {
200
+ return {
201
+ remaining: -1,
202
+ limit: -1,
203
+ period: "month"
204
+ };
205
+ }
206
+ // ============================================================================
207
+ // IContentExtractorPlugin Interface
208
+ // ============================================================================
209
+ async extract(options) {
210
+ const startTime = Date.now();
211
+ if (!isSafeWebhookUrl(options.url)) {
212
+ return {
213
+ success: false,
214
+ url: options.url,
215
+ error: `URL host is not safe to extract (SSRF guard blocked: ${options.url})`,
216
+ duration: Date.now() - startTime
217
+ };
218
+ }
219
+ try {
220
+ const client = this.getClient(options.settings);
221
+ const response = await client.getContents([options.url], { text: true, livecrawl: "fallback" });
222
+ if (!response.results || response.results.length === 0) {
223
+ return {
224
+ success: false,
225
+ url: options.url,
226
+ error: "No content extracted",
227
+ duration: Date.now() - startTime
228
+ };
229
+ }
230
+ const result = response.results[0];
231
+ const text = result.text || "";
232
+ return {
233
+ success: true,
234
+ url: options.url,
235
+ finalUrl: result.url !== options.url ? result.url : void 0,
236
+ content: text,
237
+ title: result.title || void 0,
238
+ duration: Date.now() - startTime,
239
+ wordCount: text ? text.split(/\s+/).length : 0
240
+ };
241
+ } catch (error) {
242
+ return {
243
+ success: false,
244
+ url: options.url,
245
+ error: error instanceof Error ? error.message : String(error),
246
+ duration: Date.now() - startTime
247
+ };
248
+ }
249
+ }
250
+ async extractBatch(urls, options) {
251
+ const startTime = Date.now();
252
+ const results = new Array(urls.length);
253
+ const safeUrls = [];
254
+ const safeIndexes = [];
255
+ urls.forEach((url, index) => {
256
+ if (isSafeWebhookUrl(url)) {
257
+ safeUrls.push(url);
258
+ safeIndexes.push(index);
259
+ } else {
260
+ results[index] = {
261
+ success: false,
262
+ url,
263
+ error: `URL host is not safe to extract (SSRF guard blocked: ${url})`,
264
+ duration: Date.now() - startTime
265
+ };
266
+ }
267
+ });
268
+ if (safeUrls.length === 0) {
269
+ return results;
270
+ }
271
+ try {
272
+ const client = this.getClient(options?.settings);
273
+ const response = await client.getContents([...safeUrls], { text: true, livecrawl: "fallback" });
274
+ response.results.forEach((result, i) => {
275
+ const originalIndex = safeIndexes[i];
276
+ const text = result.text || "";
277
+ const requestedUrl = safeUrls[i] || result.url;
278
+ results[originalIndex] = {
279
+ success: true,
280
+ url: requestedUrl,
281
+ finalUrl: result.url !== requestedUrl ? result.url : void 0,
282
+ content: text,
283
+ title: result.title || void 0,
284
+ duration: Date.now() - startTime,
285
+ wordCount: text ? text.split(/\s+/).length : 0
286
+ };
287
+ });
288
+ safeIndexes.forEach((originalIndex, i) => {
289
+ if (!results[originalIndex]) {
290
+ results[originalIndex] = {
291
+ success: false,
292
+ url: safeUrls[i],
293
+ error: "No content extracted",
294
+ duration: Date.now() - startTime
295
+ };
296
+ }
297
+ });
298
+ return results;
299
+ } catch (error) {
300
+ safeIndexes.forEach((originalIndex, i) => {
301
+ results[originalIndex] = {
302
+ success: false,
303
+ url: safeUrls[i],
304
+ error: error instanceof Error ? error.message : String(error),
305
+ duration: Date.now() - startTime
306
+ };
307
+ });
308
+ return results;
309
+ }
310
+ }
311
+ async canExtract(url) {
312
+ try {
313
+ const parsed = new URL(url);
314
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
315
+ } catch {
316
+ return false;
317
+ }
318
+ }
319
+ getSupportedFormats() {
320
+ return ["text"];
321
+ }
322
+ // ============================================================================
323
+ // IPlugin Lifecycle
324
+ // ============================================================================
325
+ getClient(settings) {
326
+ const apiKey = settings?.apiKey;
327
+ if (!apiKey) {
328
+ throw new Error(API_KEY_ERROR);
329
+ }
330
+ return new exaJs.Exa(apiKey);
331
+ }
332
+ async onLoad(context) {
333
+ this.context = context;
334
+ context.logger.log("Exa Plugin loaded");
335
+ }
336
+ async onUnload() {
337
+ this.context = void 0;
338
+ }
339
+ async healthCheck() {
340
+ return {
341
+ status: "healthy",
342
+ message: "Exa plugin is ready (API key required for operations)",
343
+ checkedAt: Date.now()
344
+ };
345
+ }
346
+ getManifest() {
347
+ return {
348
+ id: this.id,
349
+ name: this.name,
350
+ version: this.version,
351
+ description: "AI-native search and content extraction using the Exa API",
352
+ category: this.category,
353
+ capabilities: [...this.capabilities],
354
+ author: { name: "Ever Works Team" },
355
+ license: "AGPL-3.0",
356
+ builtIn: true,
357
+ systemPlugin: false,
358
+ autoEnable: false,
359
+ readme: [
360
+ "## What does Exa do?",
361
+ "",
362
+ "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.",
363
+ "",
364
+ "## Why use it?",
365
+ "",
366
+ "- **Neural search** \u2014 finds results based on meaning, not just keyword matching",
367
+ "- **Content extraction** \u2014 pulls clean text from any web page URL",
368
+ "- **Category filtering** \u2014 restrict to companies, research papers, news, tweets, GitHub repos, or personal sites",
369
+ "- **Domain control** \u2014 include or exclude specific domains from results",
370
+ "- **Time filtering** \u2014 find results from the last day, week, month, or year",
371
+ "",
372
+ "## How it works in Ever Works",
373
+ "",
374
+ "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.",
375
+ "",
376
+ "## Getting started",
377
+ "",
378
+ "1. Create an account at [exa.ai](https://exa.ai)",
379
+ "2. Copy your API key from the Exa dashboard",
380
+ "3. Enter the key in the **API Key** field below",
381
+ "4. Choose your preferred search type (auto recommended)",
382
+ "5. Enable this plugin to use it for work generation"
383
+ ].join("\n"),
384
+ homepage: "https://exa.ai"
385
+ };
386
+ }
387
+ };
388
+
389
+ exports.ExaSearchPlugin = ExaSearchPlugin;
390
+ exports.default = ExaSearchPlugin;
@@ -0,0 +1,28 @@
1
+ import { IPlugin, ISearchPlugin, IContentExtractorPlugin, PluginCategory, JsonSchema, SearchOptions, SearchResponse, ConnectionValidationResult, RateLimitInfo, ContentExtractionOptions, ContentExtractionResult, PluginContext, PluginHealthCheck, PluginManifest } from '@ever-works/plugin';
2
+
3
+ declare class ExaSearchPlugin implements IPlugin, ISearchPlugin, IContentExtractorPlugin {
4
+ readonly id = "exa";
5
+ readonly name = "Exa";
6
+ readonly version = "1.0.0";
7
+ readonly category: PluginCategory;
8
+ readonly capabilities: readonly string[];
9
+ readonly providerName = "Exa";
10
+ readonly settingsSchema: JsonSchema;
11
+ readonly configurationMode: 'admin-only' | 'user-required' | 'hybrid';
12
+ private context?;
13
+ search(options: SearchOptions): Promise<SearchResponse>;
14
+ isAvailable(): Promise<boolean>;
15
+ validateConnection(settings: Record<string, unknown>): Promise<ConnectionValidationResult>;
16
+ getRateLimitInfo(): Promise<RateLimitInfo>;
17
+ extract(options: ContentExtractionOptions): Promise<ContentExtractionResult>;
18
+ extractBatch(urls: readonly string[], options?: Partial<ContentExtractionOptions>): Promise<readonly ContentExtractionResult[]>;
19
+ canExtract(url: string): Promise<boolean>;
20
+ getSupportedFormats(): readonly ('text' | 'html' | 'markdown')[];
21
+ private getClient;
22
+ onLoad(context: PluginContext): Promise<void>;
23
+ onUnload(): Promise<void>;
24
+ healthCheck(): Promise<PluginHealthCheck>;
25
+ getManifest(): PluginManifest;
26
+ }
27
+
28
+ export { ExaSearchPlugin, ExaSearchPlugin as default };
@@ -0,0 +1,28 @@
1
+ import { IPlugin, ISearchPlugin, IContentExtractorPlugin, PluginCategory, JsonSchema, SearchOptions, SearchResponse, ConnectionValidationResult, RateLimitInfo, ContentExtractionOptions, ContentExtractionResult, PluginContext, PluginHealthCheck, PluginManifest } from '@ever-works/plugin';
2
+
3
+ declare class ExaSearchPlugin implements IPlugin, ISearchPlugin, IContentExtractorPlugin {
4
+ readonly id = "exa";
5
+ readonly name = "Exa";
6
+ readonly version = "1.0.0";
7
+ readonly category: PluginCategory;
8
+ readonly capabilities: readonly string[];
9
+ readonly providerName = "Exa";
10
+ readonly settingsSchema: JsonSchema;
11
+ readonly configurationMode: 'admin-only' | 'user-required' | 'hybrid';
12
+ private context?;
13
+ search(options: SearchOptions): Promise<SearchResponse>;
14
+ isAvailable(): Promise<boolean>;
15
+ validateConnection(settings: Record<string, unknown>): Promise<ConnectionValidationResult>;
16
+ getRateLimitInfo(): Promise<RateLimitInfo>;
17
+ extract(options: ContentExtractionOptions): Promise<ContentExtractionResult>;
18
+ extractBatch(urls: readonly string[], options?: Partial<ContentExtractionOptions>): Promise<readonly ContentExtractionResult[]>;
19
+ canExtract(url: string): Promise<boolean>;
20
+ getSupportedFormats(): readonly ('text' | 'html' | 'markdown')[];
21
+ private getClient;
22
+ onLoad(context: PluginContext): Promise<void>;
23
+ onUnload(): Promise<void>;
24
+ healthCheck(): Promise<PluginHealthCheck>;
25
+ getManifest(): PluginManifest;
26
+ }
27
+
28
+ export { ExaSearchPlugin, ExaSearchPlugin as default };