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