@ever-works/valyu-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.
@@ -0,0 +1,365 @@
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 { Valyu, type SearchOptions as ValyuSearchOptions } from 'valyu-js';
21
+ import { isSafeWebhookUrl } from '@ever-works/plugin/helpers/ssrf-guard';
22
+
23
+ export class ValyuSearchPlugin implements IPlugin, ISearchPlugin, IContentExtractorPlugin {
24
+ readonly id = 'valyu';
25
+ readonly name = 'Valyu';
26
+ readonly version = '1.0.0';
27
+ readonly category: PluginCategory = 'search';
28
+ readonly capabilities: readonly string[] = ['search', 'content-extractor'];
29
+ readonly providerName = 'Valyu';
30
+
31
+ readonly settingsSchema: JsonSchema = {
32
+ type: 'object',
33
+ properties: {
34
+ apiKey: {
35
+ type: 'string',
36
+ title: 'API Key',
37
+ description: 'Your Valyu API key. Get one at https://valyu.ai',
38
+ 'x-secret': true,
39
+ 'x-envVar': 'PLUGIN_VALYU_API_KEY',
40
+ 'x-scope': 'user'
41
+ },
42
+ responseLength: {
43
+ type: 'string',
44
+ title: 'Response Length',
45
+ description:
46
+ 'Content volume per result: short (~25k chars), medium (~50k), large (~100k), or max (unlimited)',
47
+ enum: ['short', 'medium', 'large', 'max'],
48
+ default: 'medium'
49
+ }
50
+ },
51
+ required: ['apiKey']
52
+ };
53
+
54
+ readonly configurationMode: 'admin-only' | 'user-required' | 'hybrid' = 'hybrid';
55
+
56
+ private context?: PluginContext;
57
+
58
+ async search(options: SearchOptions): Promise<SearchResponse> {
59
+ const client = this.getClient(options.settings);
60
+ const startTime = Date.now();
61
+
62
+ const maxNumResults = options.limit || 20;
63
+ const responseLength = (options.settings?.responseLength as ValyuSearchOptions['responseLength']) || 'medium';
64
+
65
+ try {
66
+ const searchOptions: ValyuSearchOptions = {
67
+ searchType: 'all',
68
+ maxNumResults,
69
+ responseLength
70
+ };
71
+
72
+ if (options.includeDomains && options.includeDomains.length > 0) {
73
+ searchOptions.includedSources = options.includeDomains as string[];
74
+ } else if (options.excludeDomains && options.excludeDomains.length > 0) {
75
+ searchOptions.excludeSources = options.excludeDomains as string[];
76
+ }
77
+
78
+ if (options.region) {
79
+ searchOptions.countryCode = options.region.toUpperCase() as ValyuSearchOptions['countryCode'];
80
+ }
81
+
82
+ if (options.timeRange && options.timeRange !== 'all') {
83
+ const now = new Date();
84
+ const start = new Date(now);
85
+ if (options.timeRange === 'day') start.setDate(now.getDate() - 1);
86
+ else if (options.timeRange === 'week') start.setDate(now.getDate() - 7);
87
+ else if (options.timeRange === 'month') start.setMonth(now.getMonth() - 1);
88
+ else if (options.timeRange === 'year') start.setFullYear(now.getFullYear() - 1);
89
+ searchOptions.startDate = start.toISOString().split('T')[0];
90
+ searchOptions.endDate = now.toISOString().split('T')[0];
91
+ }
92
+
93
+ const response = await client.search(options.query, searchOptions);
94
+
95
+ const results: SearchResult[] = (response.results || []).map((r, index) => ({
96
+ title: r.title,
97
+ url: r.url,
98
+ snippet: typeof r.content === 'string' ? r.content : String(r.content),
99
+ position: index + 1,
100
+ publishedDate: r.publication_date,
101
+ metadata: {
102
+ relevanceScore: r.relevance_score,
103
+ source: r.source,
104
+ description: r.description
105
+ }
106
+ }));
107
+
108
+ return {
109
+ results,
110
+ query: options.query,
111
+ totalResults: results.length,
112
+ hasMore: false,
113
+ duration: Date.now() - startTime
114
+ };
115
+ } catch (error) {
116
+ this.context?.logger.error(
117
+ `Valyu search failed: ${error instanceof Error ? error.message : String(error)}`
118
+ );
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ async isAvailable(): Promise<boolean> {
124
+ if (!this.context) return false;
125
+ const settings = await this.context.getSettings();
126
+ return Boolean(settings?.apiKey);
127
+ }
128
+
129
+ async validateConnection(settings: Record<string, unknown>): Promise<ConnectionValidationResult> {
130
+ const apiKey = settings.apiKey as string | undefined;
131
+ if (!apiKey) {
132
+ return { success: false, message: 'Valyu API key is not configured.' };
133
+ }
134
+
135
+ try {
136
+ const client = this.getClient(settings);
137
+ await client.search('test', { maxNumResults: 1, searchType: 'all' });
138
+ return { success: true, message: 'Valyu connection verified.' };
139
+ } catch (error) {
140
+ return {
141
+ success: false,
142
+ message: `Valyu connection failed: ${error instanceof Error ? error.message : String(error)}`
143
+ };
144
+ }
145
+ }
146
+
147
+ async getRateLimitInfo(): Promise<RateLimitInfo> {
148
+ return {
149
+ remaining: -1,
150
+ limit: -1,
151
+ period: 'month'
152
+ };
153
+ }
154
+
155
+ async extract(options: ContentExtractionOptions): Promise<ContentExtractionResult> {
156
+ const client = this.getClient(options.settings);
157
+ const startTime = Date.now();
158
+
159
+ // Security (SSRF): refuse to hand private/loopback/link-local/cloud-metadata
160
+ // URLs to the Valyu SDK, which would fetch them server-side and return the
161
+ // rendered content. `canExtract()` is advisory and may be bypassed by a
162
+ // direct call, so enforce the guard here. The Valyu SDK performs the fetch
163
+ // internally, so redirect targets can't be re-checked — this is the lexical
164
+ // first line of defense for the initial URL.
165
+ if (!isSafeWebhookUrl(options.url)) {
166
+ return {
167
+ success: false,
168
+ url: options.url,
169
+ error: `URL host is not safe to fetch (SSRF guard blocked: ${options.url})`,
170
+ duration: Date.now() - startTime
171
+ };
172
+ }
173
+
174
+ try {
175
+ const response = await client.contents([options.url]);
176
+
177
+ if (!response.results || response.results.length === 0) {
178
+ return {
179
+ success: false,
180
+ url: options.url,
181
+ error: 'No content extracted',
182
+ duration: Date.now() - startTime
183
+ };
184
+ }
185
+
186
+ const result = response.results[0];
187
+ const content = typeof result.content === 'string' ? result.content : String(result.content || '');
188
+
189
+ return {
190
+ success: true,
191
+ url: options.url,
192
+ finalUrl: result.url !== options.url ? result.url : undefined,
193
+ title: result.title,
194
+ content,
195
+ markdown: content,
196
+ duration: Date.now() - startTime,
197
+ wordCount: content ? content.split(/\s+/).length : 0
198
+ };
199
+ } catch (error) {
200
+ return {
201
+ success: false,
202
+ url: options.url,
203
+ error: error instanceof Error ? error.message : String(error),
204
+ duration: Date.now() - startTime
205
+ };
206
+ }
207
+ }
208
+
209
+ async extractBatch(
210
+ urls: readonly string[],
211
+ options?: Partial<ContentExtractionOptions>
212
+ ): Promise<readonly ContentExtractionResult[]> {
213
+ const client = this.getClient(options?.settings);
214
+ const startTime = Date.now();
215
+
216
+ // Security (SSRF): drop private/loopback/link-local/cloud-metadata URLs
217
+ // before they reach the Valyu SDK (which would fetch them server-side),
218
+ // emitting a failed result for each. This also stops a single batch call
219
+ // from being used to probe many internal hosts/ports at once. Legitimate
220
+ // public URLs are unaffected.
221
+ const safeUrls: string[] = [];
222
+ const blockedResults: ContentExtractionResult[] = [];
223
+ for (const url of urls) {
224
+ if (isSafeWebhookUrl(url)) {
225
+ safeUrls.push(url);
226
+ } else {
227
+ blockedResults.push({
228
+ success: false,
229
+ url,
230
+ error: `URL host is not safe to fetch (SSRF guard blocked: ${url})`,
231
+ duration: Date.now() - startTime
232
+ });
233
+ }
234
+ }
235
+
236
+ try {
237
+ const batchSize = 10;
238
+ const allResults: ContentExtractionResult[] = [];
239
+
240
+ for (let i = 0; i < safeUrls.length; i += batchSize) {
241
+ const batch = safeUrls.slice(i, i + batchSize);
242
+ const response = await client.contents(batch as string[]);
243
+
244
+ const results = (response.results || []).map((result, index) => {
245
+ const content = typeof result.content === 'string' ? result.content : String(result.content || '');
246
+ const requestedUrl = batch[index] || result.url;
247
+ return {
248
+ success: true,
249
+ url: requestedUrl,
250
+ finalUrl: result.url !== requestedUrl ? result.url : undefined,
251
+ title: result.title,
252
+ content,
253
+ markdown: content,
254
+ duration: Date.now() - startTime,
255
+ wordCount: content ? content.split(/\s+/).length : 0
256
+ };
257
+ });
258
+
259
+ allResults.push(...results);
260
+ }
261
+
262
+ return [...blockedResults, ...allResults];
263
+ } catch (error) {
264
+ // Only the safe URLs were sent to the SDK, so surface the fetch error
265
+ // for those; blocked URLs already carry their SSRF-guard reason.
266
+ const failed = safeUrls.map((url) => ({
267
+ success: false,
268
+ url,
269
+ error: error instanceof Error ? error.message : String(error),
270
+ duration: Date.now() - startTime
271
+ }));
272
+ return [...blockedResults, ...failed];
273
+ }
274
+ }
275
+
276
+ async canExtract(url: string): Promise<boolean> {
277
+ // Security (SSRF): `isSafeWebhookUrl` enforces http(s) AND rejects
278
+ // private/loopback/link-local/cloud-metadata hosts, so callers that gate
279
+ // on canExtract() won't even surface an internal URL as extractable.
280
+ // (extract()/extractBatch() re-check independently in case this is bypassed.)
281
+ return isSafeWebhookUrl(url);
282
+ }
283
+
284
+ getSupportedFormats(): readonly ('text' | 'html' | 'markdown')[] {
285
+ return ['text', 'markdown'];
286
+ }
287
+
288
+ private getClient(settings?: PluginSettings): Valyu {
289
+ const apiKey = settings?.apiKey as string;
290
+
291
+ if (!apiKey) {
292
+ throw new Error(
293
+ 'Valyu API key not configured. ' +
294
+ 'Set it in plugin settings or via PLUGIN_VALYU_API_KEY environment variable.'
295
+ );
296
+ }
297
+
298
+ return new Valyu(apiKey);
299
+ }
300
+
301
+ async onLoad(context: PluginContext): Promise<void> {
302
+ this.context = context;
303
+ context.logger.log('Valyu Plugin loaded');
304
+ }
305
+
306
+ async onUnload(): Promise<void> {
307
+ this.context = undefined;
308
+ }
309
+
310
+ async healthCheck(): Promise<PluginHealthCheck> {
311
+ return {
312
+ status: 'healthy',
313
+ message: 'Valyu plugin is ready (API key required for operations)',
314
+ checkedAt: Date.now()
315
+ };
316
+ }
317
+
318
+ getManifest(): PluginManifest {
319
+ return {
320
+ id: this.id,
321
+ name: this.name,
322
+ version: this.version,
323
+ description: 'AI-native search and content extraction across web and proprietary data sources',
324
+ category: this.category,
325
+ capabilities: [...this.capabilities],
326
+ author: { name: 'Ever Works Team' },
327
+ license: 'AGPL-3.0',
328
+ builtIn: true,
329
+ systemPlugin: false,
330
+ autoEnable: false,
331
+ homepage: 'https://valyu.ai',
332
+ icon: {
333
+ type: 'svg',
334
+ value: `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet"><g fill="#1e1d1d" fill-opacity="1"><g transform="translate(-3, 88)"><path d="M 48.3125 1.734375 L 59.78125 1.734375 L 91.5625 -71.453125 L 91.5625 -77.5 L 16.328125 -77.5 L 16.328125 -71.453125 Z M 64.421875 -32.640625 L 54.15625 -7.140625 L 53.609375 -7.140625 L 43.234375 -32.640625 L 27.78125 -68.96875 L 79.984375 -68.96875 Z M 64.421875 -32.640625 "/></g></g></svg>`,
335
+ darkValue: `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet"><g fill="#ebebeb" fill-opacity="1"><g transform="translate(-3, 88)"><path d="M 48.3125 1.734375 L 59.78125 1.734375 L 91.5625 -71.453125 L 91.5625 -77.5 L 16.328125 -77.5 L 16.328125 -71.453125 Z M 64.421875 -32.640625 L 54.15625 -7.140625 L 53.609375 -7.140625 L 43.234375 -32.640625 L 27.78125 -68.96875 L 79.984375 -68.96875 Z M 64.421875 -32.640625 "/></g></g></svg>`
336
+ },
337
+ readme: [
338
+ '## What does Valyu do?',
339
+ '',
340
+ 'Valyu is an AI-native search and content extraction service that searches across web and proprietary data sources. It returns results optimized for AI applications and RAG pipelines, providing the source material that Ever Works uses to generate accurate work items.',
341
+ '',
342
+ '## Why use it?',
343
+ '',
344
+ '- **Multi-source search** — search across web, proprietary datasets (arXiv, PubMed, financial data), and news',
345
+ '- **AI-optimized results** — returns content formatted for AI processing with relevance scoring',
346
+ '- **Content extraction** — pulls clean text and markdown from web pages',
347
+ '- **Domain filtering** — include or exclude specific websites from search results',
348
+ '- **Date filtering** — restrict results to specific time periods',
349
+ '',
350
+ '## How it works in Ever Works',
351
+ '',
352
+ 'During work generation, the search facade uses Valyu to find information about each item, discover relevant source URLs, and extract content from web pages. This powers automatic descriptions, source URL resolution, and content enrichment across the generation pipeline.',
353
+ '',
354
+ '## Getting started',
355
+ '',
356
+ '1. Create an account at [valyu.ai](https://valyu.ai)',
357
+ '2. Copy your API key from the Valyu dashboard',
358
+ '3. Enter the key in the **API Key** field below',
359
+ '4. Valyu will be used during work generation when selected as the search provider'
360
+ ].join('\n')
361
+ };
362
+ }
363
+ }
364
+
365
+ export default ValyuSearchPlugin;
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
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ noExternal: ['@ever-works/plugin'],
6
+ format: ['cjs', 'esm'],
7
+ dts: true,
8
+ clean: true,
9
+ sourcemap: false,
10
+ splitting: false,
11
+ treeshake: true
12
+ });