@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.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @ever-works/valyu-plugin
2
+
3
+ Valyu Plugin - Web search and content extraction using Valyu API
4
+
5
+ ## Plugin metadata
6
+
7
+ | Field | Value |
8
+ | ------------ | ----------------------------- |
9
+ | ID | `valyu` |
10
+ | Category | `search` |
11
+ | Capabilities | `search`, `content-extractor` |
12
+ | Author | Ever Works Team |
13
+ | License | AGPL-3.0 |
14
+ | Built-in | yes |
15
+ | Auto-enable | no |
16
+
17
+ ## What does Valyu do?
18
+
19
+ 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.
20
+
21
+ ## Why use it?
22
+
23
+ - **Multi-source search** — search across web, proprietary datasets (arXiv, PubMed, financial data), and news
24
+ - **AI-optimized results** — returns content formatted for AI processing with relevance scoring
25
+ - **Content extraction** — pulls clean text and markdown from web pages
26
+ - **Domain filtering** — include or exclude specific websites from search results
27
+ - **Date filtering** — restrict results to specific time periods
28
+
29
+ ## How it works in Ever Works
30
+
31
+ 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.
32
+
33
+ ## Getting started
34
+
35
+ 1. Create an account at [valyu.ai](https://valyu.ai)
36
+ 2. Copy your API key from the Valyu dashboard
37
+ 3. Enter the key in the **API Key** field below
38
+ 4. Valyu will be used during work generation when selected as the search provider
39
+
40
+ ## Settings
41
+
42
+ - **API Key** (`apiKey`) — required, secret. Your Valyu API key (also configurable via the `PLUGIN_VALYU_API_KEY` environment variable).
43
+ - **Response Length** (`responseLength`) — optional, one of `short` (~25k chars), `medium` (~50k, default), `large` (~100k), or `max` (unlimited). Controls content volume per result.
44
+ - API key is stored at user scope so each user can supply their own credentials.
45
+ - Configuration mode is `hybrid` — admins can preset values and users may override them.
46
+
47
+ ## Troubleshooting
48
+
49
+ | Symptom | Likely cause | Fix |
50
+ | -------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
+ | `401 Unauthorized` / `Authentication failed` | API key missing, revoked, or wrong key entered | Re-enter the **API Key** from the Valyu dashboard, or set `PLUGIN_VALYU_API_KEY` in the host environment for default fallback |
52
+ | `429 Too Many Requests` | Free-tier or paid quota exhausted on Valyu | Throttle calls, wait for the quota reset, or upgrade the plan in the Valyu dashboard |
53
+ | Empty / sparse results | Query is too restrictive, time-range or domain filters are too narrow | Broaden the query, relax `time_range` / `safesearch` / `include_domains` / `exclude_domains` filters, or raise `max_results` |
54
+ | Plugin not used during work generation | Another search plugin is set as the default for the `search` capability | In **Settings → Plugins**, set `valyu` as the default for `search`, or disable competing search plugins |
55
+ | `Failed to extract content` for a URL | Page is gated by login, Cloudflare, or robots.txt; URL is malformed | Verify the URL is publicly reachable; for protected pages enable a different `content-extractor` plugin (`scrapfly`, `notion-extractor`, `pdf-extractor`) |
56
+ | `healthCheck` reports unhealthy | API key invalid OR Valyu endpoint unreachable from the host | Verify the key with a manual `curl` against the documented endpoint and confirm outbound HTTPS is allowed by the firewall |
57
+
58
+ ## Local development
59
+
60
+ This plugin ships built-in with the Ever Works platform. To work on it locally from the monorepo root:
61
+
62
+ ```bash
63
+ pnpm install
64
+ pnpm --filter @ever-works/valyu-plugin build
65
+ pnpm --filter @ever-works/valyu-plugin test
66
+ ```
67
+
68
+ ## Documentation
69
+
70
+ - [Ever Works documentation](https://docs.ever.works)
71
+ - [Ever Works repository](https://github.com/ever-works/ever-works)
72
+ - [Plugin system](../../plugin/README.md)
73
+ - [Valyu homepage](https://valyu.ai)
74
+
75
+ ## License
76
+
77
+ AGPL-3.0
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@ever-works/valyu-plugin",
3
+ "version": "1.0.0",
4
+ "description": "Valyu Plugin - Web search and content extraction using Valyu 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
+ "valyu-js": "^2.5.4"
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": "valyu",
39
+ "name": "Valyu",
40
+ "version": "1.0.0",
41
+ "category": "search",
42
+ "capabilities": [
43
+ "search",
44
+ "content-extractor"
45
+ ],
46
+ "description": "AI-native search and content extraction using the Valyu API. Supports web, proprietary, and news search with optional content extraction from URLs.",
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_VALYU_API_KEY",
57
+ "required": false,
58
+ "secret": true,
59
+ "description": "Valyu 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
+ }
@@ -0,0 +1,321 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { PluginContext, SearchOptions, ContentExtractionOptions } from '@ever-works/plugin';
3
+
4
+ const { searchMock, contentsMock, ValyuCtorMock } = vi.hoisted(() => {
5
+ const search = vi.fn();
6
+ const contents = vi.fn();
7
+ const ctor = vi.fn().mockImplementation(function () {
8
+ return { search, contents };
9
+ });
10
+ return { searchMock: search, contentsMock: contents, ValyuCtorMock: ctor };
11
+ });
12
+
13
+ vi.mock('valyu-js', () => ({
14
+ Valyu: ValyuCtorMock
15
+ }));
16
+
17
+ const { ValyuSearchPlugin } = await import('../valyu.plugin.js');
18
+
19
+ const buildContext = (settings: Record<string, unknown> = {}): PluginContext =>
20
+ ({
21
+ pluginId: 'valyu',
22
+ logger: {
23
+ log: vi.fn(),
24
+ debug: vi.fn(),
25
+ warn: vi.fn(),
26
+ error: vi.fn()
27
+ },
28
+ getSettings: vi.fn().mockResolvedValue(settings)
29
+ }) as unknown as PluginContext;
30
+
31
+ describe('ValyuSearchPlugin', () => {
32
+ let plugin: ValyuSearchPlugin;
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ plugin = new ValyuSearchPlugin();
37
+ });
38
+
39
+ describe('metadata', () => {
40
+ it('exposes stable identity fields', () => {
41
+ expect(plugin.id).toBe('valyu');
42
+ expect(plugin.name).toBe('Valyu');
43
+ expect(plugin.version).toBe('1.0.0');
44
+ expect(plugin.category).toBe('search');
45
+ expect(plugin.providerName).toBe('Valyu');
46
+ });
47
+
48
+ it('declares both search and content-extractor capabilities', () => {
49
+ expect(plugin.capabilities).toEqual(['search', 'content-extractor']);
50
+ });
51
+
52
+ it('uses hybrid configuration mode', () => {
53
+ expect(plugin.configurationMode).toBe('hybrid');
54
+ });
55
+ });
56
+
57
+ describe('settingsSchema', () => {
58
+ it('requires apiKey and exposes a responseLength enum', () => {
59
+ expect(plugin.settingsSchema.required).toContain('apiKey');
60
+ const props = plugin.settingsSchema.properties as Record<string, Record<string, unknown>>;
61
+ expect(props.apiKey['x-secret']).toBe(true);
62
+ expect(props.apiKey['x-envVar']).toBe('PLUGIN_VALYU_API_KEY');
63
+ expect(props.responseLength.default).toBe('medium');
64
+ expect(props.responseLength.enum).toEqual(['short', 'medium', 'large', 'max']);
65
+ });
66
+ });
67
+
68
+ describe('search', () => {
69
+ const opts = (overrides: Partial<SearchOptions> = {}): SearchOptions => ({
70
+ query: 'hello',
71
+ settings: { apiKey: 'k' },
72
+ ...overrides
73
+ });
74
+
75
+ it('throws when apiKey is missing', async () => {
76
+ await expect(plugin.search({ query: 'hello', settings: {} })).rejects.toThrow(/API key not configured/i);
77
+ expect(ValyuCtorMock).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('maps SDK results to SearchResult shape', async () => {
81
+ searchMock.mockResolvedValueOnce({
82
+ results: [
83
+ {
84
+ title: 'Example',
85
+ url: 'https://example.com',
86
+ content: 'snippet text',
87
+ relevance_score: 0.7,
88
+ source: 'web',
89
+ description: 'desc',
90
+ publication_date: '2026-01-01'
91
+ }
92
+ ]
93
+ });
94
+ const r = await plugin.search(opts());
95
+
96
+ expect(ValyuCtorMock).toHaveBeenCalledWith('k');
97
+ expect(r.results).toHaveLength(1);
98
+ expect(r.results[0]).toMatchObject({
99
+ title: 'Example',
100
+ url: 'https://example.com',
101
+ snippet: 'snippet text',
102
+ position: 1,
103
+ publishedDate: '2026-01-01'
104
+ });
105
+ expect(r.results[0].metadata).toMatchObject({
106
+ relevanceScore: 0.7,
107
+ source: 'web',
108
+ description: 'desc'
109
+ });
110
+ expect(r.totalResults).toBe(1);
111
+ expect(r.hasMore).toBe(false);
112
+ });
113
+
114
+ it('coerces non-string content to string', async () => {
115
+ searchMock.mockResolvedValueOnce({
116
+ results: [{ title: 't', url: 'https://e', content: { foo: 'bar' } }]
117
+ });
118
+ const r = await plugin.search(opts());
119
+ expect(typeof r.results[0].snippet).toBe('string');
120
+ });
121
+
122
+ it('forwards limit, responseLength, and country code', async () => {
123
+ searchMock.mockResolvedValueOnce({ results: [] });
124
+ await plugin.search(
125
+ opts({
126
+ limit: 7,
127
+ region: 'us',
128
+ settings: { apiKey: 'k', responseLength: 'large' }
129
+ })
130
+ );
131
+ expect(searchMock).toHaveBeenCalledWith(
132
+ 'hello',
133
+ expect.objectContaining({
134
+ searchType: 'all',
135
+ maxNumResults: 7,
136
+ responseLength: 'large',
137
+ countryCode: 'US'
138
+ })
139
+ );
140
+ });
141
+
142
+ it('uses includedSources when includeDomains is set', async () => {
143
+ searchMock.mockResolvedValueOnce({ results: [] });
144
+ await plugin.search(opts({ includeDomains: ['a.com'] }));
145
+ expect(searchMock.mock.calls[0][1]).toMatchObject({ includedSources: ['a.com'] });
146
+ });
147
+
148
+ it('uses excludeSources when only excludeDomains is set', async () => {
149
+ searchMock.mockResolvedValueOnce({ results: [] });
150
+ await plugin.search(opts({ excludeDomains: ['b.com'] }));
151
+ expect(searchMock.mock.calls[0][1]).toMatchObject({ excludeSources: ['b.com'] });
152
+ });
153
+
154
+ it('translates timeRange into start/end ISO date strings', async () => {
155
+ searchMock.mockResolvedValueOnce({ results: [] });
156
+ await plugin.search(opts({ timeRange: 'week' }));
157
+ const call = searchMock.mock.calls[0][1];
158
+ expect(call.startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
159
+ expect(call.endDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
160
+ });
161
+
162
+ it('skips date filters when timeRange is "all"', async () => {
163
+ searchMock.mockResolvedValueOnce({ results: [] });
164
+ await plugin.search(opts({ timeRange: 'all' }));
165
+ const call = searchMock.mock.calls[0][1];
166
+ expect(call.startDate).toBeUndefined();
167
+ expect(call.endDate).toBeUndefined();
168
+ });
169
+
170
+ it('logs and rethrows on SDK failure', async () => {
171
+ const ctx = buildContext();
172
+ await plugin.onLoad(ctx);
173
+ searchMock.mockRejectedValueOnce(new Error('boom'));
174
+ await expect(plugin.search(opts())).rejects.toThrow(/boom/);
175
+ expect(ctx.logger.error).toHaveBeenCalled();
176
+ });
177
+ });
178
+
179
+ describe('isAvailable', () => {
180
+ it('returns false without context', async () => {
181
+ expect(await plugin.isAvailable()).toBe(false);
182
+ });
183
+
184
+ it('reflects whether settings has an apiKey', async () => {
185
+ await plugin.onLoad(buildContext({ apiKey: 'k' }));
186
+ expect(await plugin.isAvailable()).toBe(true);
187
+
188
+ await plugin.onLoad(buildContext({}));
189
+ expect(await plugin.isAvailable()).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe('validateConnection', () => {
194
+ it('fails fast without apiKey', async () => {
195
+ const r = await plugin.validateConnection({});
196
+ expect(r.success).toBe(false);
197
+ expect(r.message).toMatch(/not configured/i);
198
+ });
199
+
200
+ it('returns success when SDK search works', async () => {
201
+ searchMock.mockResolvedValueOnce({ results: [] });
202
+ const r = await plugin.validateConnection({ apiKey: 'k' });
203
+ expect(r.success).toBe(true);
204
+ });
205
+
206
+ it('returns failure when SDK search rejects', async () => {
207
+ searchMock.mockRejectedValueOnce(new Error('bad key'));
208
+ const r = await plugin.validateConnection({ apiKey: 'k' });
209
+ expect(r.success).toBe(false);
210
+ expect(r.message).toMatch(/bad key/);
211
+ });
212
+ });
213
+
214
+ describe('extract', () => {
215
+ const opts = (overrides: Partial<ContentExtractionOptions> = {}): ContentExtractionOptions => ({
216
+ url: 'https://example.com/post',
217
+ settings: { apiKey: 'k' },
218
+ ...overrides
219
+ });
220
+
221
+ it('returns success with markdown and word count', async () => {
222
+ contentsMock.mockResolvedValueOnce({
223
+ results: [
224
+ {
225
+ url: 'https://example.com/post',
226
+ title: 'Title',
227
+ content: 'one two three'
228
+ }
229
+ ]
230
+ });
231
+ const r = await plugin.extract(opts());
232
+ expect(r.success).toBe(true);
233
+ expect(r.title).toBe('Title');
234
+ expect(r.markdown).toBe('one two three');
235
+ expect(r.wordCount).toBe(3);
236
+ });
237
+
238
+ it('returns failure when no results are produced', async () => {
239
+ contentsMock.mockResolvedValueOnce({ results: [] });
240
+ const r = await plugin.extract(opts());
241
+ expect(r.success).toBe(false);
242
+ expect(r.error).toMatch(/No content extracted/);
243
+ });
244
+
245
+ it('returns failure when SDK throws', async () => {
246
+ contentsMock.mockRejectedValueOnce(new Error('extract failed'));
247
+ const r = await plugin.extract(opts());
248
+ expect(r.success).toBe(false);
249
+ expect(r.error).toMatch(/extract failed/);
250
+ });
251
+ });
252
+
253
+ describe('extractBatch', () => {
254
+ it('chunks URLs into batches of 10 and aggregates results', async () => {
255
+ const urls = Array.from({ length: 12 }, (_, i) => `https://example.com/${i}`);
256
+ contentsMock
257
+ .mockResolvedValueOnce({
258
+ results: urls.slice(0, 10).map((u) => ({ url: u, title: 't', content: 'a b' }))
259
+ })
260
+ .mockResolvedValueOnce({
261
+ results: urls.slice(10).map((u) => ({ url: u, title: 't', content: 'c d e' }))
262
+ });
263
+
264
+ const r = await plugin.extractBatch(urls, { settings: { apiKey: 'k' } });
265
+ expect(contentsMock).toHaveBeenCalledTimes(2);
266
+ expect(r).toHaveLength(12);
267
+ expect(r[0]).toMatchObject({ success: true, wordCount: 2 });
268
+ expect(r[10]).toMatchObject({ success: true, wordCount: 3 });
269
+ });
270
+
271
+ it('returns per-URL failures when the batch call throws', async () => {
272
+ contentsMock.mockRejectedValueOnce(new Error('batch failed'));
273
+ const r = await plugin.extractBatch(['https://a', 'https://b'], { settings: { apiKey: 'k' } });
274
+ expect(r).toHaveLength(2);
275
+ expect(r.every((x) => x.success === false)).toBe(true);
276
+ });
277
+ });
278
+
279
+ describe('canExtract / getSupportedFormats', () => {
280
+ it('accepts http and https URLs', async () => {
281
+ expect(await plugin.canExtract('https://example.com')).toBe(true);
282
+ expect(await plugin.canExtract('http://example.com')).toBe(true);
283
+ });
284
+
285
+ it('rejects non-http schemes and invalid URLs', async () => {
286
+ expect(await plugin.canExtract('ftp://example.com')).toBe(false);
287
+ expect(await plugin.canExtract('garbage')).toBe(false);
288
+ });
289
+
290
+ it('exposes text + markdown formats', () => {
291
+ expect(plugin.getSupportedFormats()).toEqual(['text', 'markdown']);
292
+ });
293
+ });
294
+
295
+ describe('lifecycle', () => {
296
+ it('logs on load and clears context on unload', async () => {
297
+ const ctx = buildContext();
298
+ await plugin.onLoad(ctx);
299
+ expect(ctx.logger.log).toHaveBeenCalledWith('Valyu Plugin loaded');
300
+ await plugin.onUnload();
301
+ expect(await plugin.isAvailable()).toBe(false);
302
+ });
303
+ });
304
+
305
+ describe('healthCheck + manifest', () => {
306
+ it('reports healthy', async () => {
307
+ const h = await plugin.healthCheck();
308
+ expect(h.status).toBe('healthy');
309
+ });
310
+
311
+ it('returns a manifest aligned with plugin metadata', () => {
312
+ const m = plugin.getManifest();
313
+ expect(m.id).toBe('valyu');
314
+ expect(m.category).toBe('search');
315
+ expect(m.capabilities).toEqual(['search', 'content-extractor']);
316
+ expect(m.builtIn).toBe(true);
317
+ expect(m.systemPlugin).toBe(false);
318
+ expect(m.autoEnable).toBe(false);
319
+ });
320
+ });
321
+ });
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { ValyuSearchPlugin } from './valyu.plugin.js';
2
+ export { ValyuSearchPlugin as default } from './valyu.plugin.js';