@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.3.1

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1213 -2
  4. package/package.json +45 -4
  5. package/src/agent/agent.js +1194 -0
  6. package/src/agent/approvals.js +76 -0
  7. package/src/agent/context.js +106 -0
  8. package/src/agent/planner.js +237 -0
  9. package/src/agent/worklog.js +318 -0
  10. package/src/cli/app.js +463 -0
  11. package/src/cli/commands.js +1150 -0
  12. package/src/cli/input.js +287 -0
  13. package/src/cli/markdown.js +527 -0
  14. package/src/cli/prompts.js +255 -0
  15. package/src/cli/renderer.js +2155 -0
  16. package/src/cli/settings-editor.js +280 -0
  17. package/src/cli/settings-tui.js +669 -0
  18. package/src/cli/tui.js +1737 -0
  19. package/src/cli/width.js +48 -0
  20. package/src/config/load.js +132 -0
  21. package/src/config/scaffold.js +50 -0
  22. package/src/config/settings-registry.js +495 -0
  23. package/src/config/settings.js +304 -0
  24. package/src/config/validate.js +451 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +282 -0
  27. package/src/providers/index.js +84 -0
  28. package/src/providers/nvidia.js +645 -0
  29. package/src/providers/registry.js +104 -0
  30. package/src/services/clipboard.js +38 -0
  31. package/src/services/diff.js +153 -0
  32. package/src/services/doctor.js +126 -0
  33. package/src/services/logger.js +190 -0
  34. package/src/services/preferences.js +249 -0
  35. package/src/services/processes.js +345 -0
  36. package/src/services/provider-settings.js +226 -0
  37. package/src/services/sessions.js +279 -0
  38. package/src/services/undo.js +133 -0
  39. package/src/services/ushell.js +146 -0
  40. package/src/services/websearch/manager.js +616 -0
  41. package/src/services/websearch/mcp.js +233 -0
  42. package/src/services/websearch/normalize.js +284 -0
  43. package/src/services/websearch/providers.js +178 -0
  44. package/src/services/websearch/serper.js +263 -0
  45. package/src/services/workspace.js +589 -0
  46. package/src/tools/filesystem.js +304 -0
  47. package/src/tools/git.js +114 -0
  48. package/src/tools/index.js +48 -0
  49. package/src/tools/project.js +160 -0
  50. package/src/tools/registry.js +118 -0
  51. package/src/tools/search.js +210 -0
  52. package/src/tools/shell.js +279 -0
  53. package/src/tools/url.js +542 -0
  54. package/src/tools/web.js +67 -0
  55. package/src/utils/errors.js +160 -0
  56. package/src/utils/network.js +129 -0
  57. package/src/utils/redaction.js +73 -0
  58. package/src/utils/validation.js +370 -0
  59. package/src/utils/version.js +24 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Minimal MCP client wrapper.
3
+ *
4
+ * Remote providers use Streamable HTTP; the DuckDuckGo option uses stdio. The
5
+ * SDK handles protocol details, while this module adds the operational rules the
6
+ * agent needs: a hard timeout per operation, sanitized child-process
7
+ * environments, and typed errors that say whether a retry is worth it.
8
+ *
9
+ * Secrets never enter this module's logs: HTTP credentials travel in headers,
10
+ * and the stdio server receives a minimal environment (PATH/HOME only), not the
11
+ * agent's NVIDIA key or other credentials.
12
+ */
13
+
14
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
15
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
16
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
17
+ import { CancelledError, SearchError } from '../../utils/errors.js';
18
+ import { readVersion } from '../../utils/version.js';
19
+
20
+ const CLIENT_INFO = { name: 'ryuu-agent-cli', version: readVersion() };
21
+
22
+ const SAFE_ENV_KEYS = [
23
+ 'PATH',
24
+ 'HOME',
25
+ 'TMPDIR',
26
+ 'TEMP',
27
+ 'TMP',
28
+ 'LANG',
29
+ 'LC_ALL',
30
+ 'SystemRoot',
31
+ 'SYSTEMROOT',
32
+ 'COMSPEC',
33
+ 'PATHEXT',
34
+ 'USERPROFILE',
35
+ 'APPDATA',
36
+ 'LOCALAPPDATA',
37
+ ];
38
+
39
+ /** Environment for a third-party stdio server: no API keys, no session secrets. */
40
+ export function safeChildEnv(extra = {}) {
41
+ const env = {};
42
+ for (const key of SAFE_ENV_KEYS) {
43
+ if (typeof process.env[key] === 'string') env[key] = process.env[key];
44
+ }
45
+ for (const [key, value] of Object.entries(extra || {})) {
46
+ if (/^(NVIDIA|TOKENHARBOR|RYUU|OPENAI|ANTHROPIC|TAVILY|FIRECRAWL|YDC|YOU|SERPER|SEARCH)_/i.test(key)) continue;
47
+ if (typeof value === 'string') env[key] = value;
48
+ }
49
+ return env;
50
+ }
51
+
52
+ function statusOf(error) {
53
+ const direct = Number(error?.status ?? error?.code);
54
+ if (Number.isInteger(direct) && direct >= 400 && direct <= 599) return direct;
55
+ const match = String(error?.message || '').match(/\b(4\d{2}|5\d{2})\b/);
56
+ return match ? Number(match[1]) : null;
57
+ }
58
+
59
+ /** Map an SDK/transport failure to a typed, actionable error. */
60
+ export function mcpError(error, { provider, phase }) {
61
+ if (error?.code === 'CANCELLED' || error?.name === 'AbortError') {
62
+ return new CancelledError('Task cancelled by user.');
63
+ }
64
+ const status = statusOf(error);
65
+ const label = provider?.label || provider?.id || 'MCP provider';
66
+ const reason = String(error?.message || error).replace(/\s+/g, ' ').slice(0, 200);
67
+ if (status === 401 || status === 403) {
68
+ return new SearchError(`${label} rejected the credentials (HTTP ${status}).`, {
69
+ code: 'MCP_AUTH',
70
+ status,
71
+ details: { provider: provider?.id, phase },
72
+ });
73
+ }
74
+ if (status === 429) {
75
+ return new SearchError(`${label} is rate limited (HTTP 429).`, {
76
+ code: 'MCP_RATE_LIMIT',
77
+ status,
78
+ retryable: true,
79
+ details: { provider: provider?.id, phase },
80
+ });
81
+ }
82
+ if (status >= 500) {
83
+ return new SearchError(`${label} is unavailable (HTTP ${status}).`, {
84
+ code: 'MCP_UPSTREAM',
85
+ status,
86
+ retryable: true,
87
+ details: { provider: provider?.id, phase },
88
+ });
89
+ }
90
+ if (/timeout|timed out|aborted/i.test(reason)) {
91
+ return new SearchError(`${label} timed out during ${phase}.`, {
92
+ code: 'MCP_TIMEOUT',
93
+ retryable: true,
94
+ details: { provider: provider?.id, phase },
95
+ });
96
+ }
97
+ if (/fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ECONNRESET|socket/i.test(reason)) {
98
+ return new SearchError(`${label} cannot be reached: ${reason}`, {
99
+ code: 'MCP_NETWORK',
100
+ retryable: true,
101
+ details: { provider: provider?.id, phase },
102
+ });
103
+ }
104
+ if (/spawn|ENOENT/i.test(reason)) {
105
+ return new SearchError(`${label} could not start: ${reason}`, {
106
+ code: 'MCP_SPAWN',
107
+ details: { provider: provider?.id, phase },
108
+ });
109
+ }
110
+ if (phase === 'handshake') {
111
+ return new SearchError(`${label} MCP handshake failed: ${reason}`, {
112
+ code: 'MCP_HANDSHAKE',
113
+ details: { provider: provider?.id, phase },
114
+ });
115
+ }
116
+ if (phase === 'discovery') {
117
+ return new SearchError(`${label} tool discovery failed: ${reason}`, {
118
+ code: 'MCP_DISCOVERY',
119
+ details: { provider: provider?.id, phase },
120
+ });
121
+ }
122
+ return new SearchError(`${label} call failed: ${reason}`, {
123
+ code: 'MCP_CALL_FAILED',
124
+ details: { provider: provider?.id, phase },
125
+ });
126
+ }
127
+
128
+ /** Race a promise against a hard deadline and an optional abort signal. */
129
+ export async function withTimeout(promise, { timeoutMs, signal = null, label = 'operation' }) {
130
+ if (signal?.aborted) throw new CancelledError('Task cancelled by user.');
131
+ let timer;
132
+ let onAbort;
133
+ const guard = new Promise((_, reject) => {
134
+ timer = setTimeout(() => {
135
+ reject(new SearchError(`${label} timed out after ${timeoutMs}ms.`, { code: 'MCP_TIMEOUT', retryable: true }));
136
+ }, timeoutMs);
137
+ if (timer.unref) timer.unref();
138
+ if (signal) {
139
+ onAbort = () => reject(new CancelledError('Task cancelled by user.'));
140
+ signal.addEventListener('abort', onAbort, { once: true });
141
+ }
142
+ });
143
+ try {
144
+ return await Promise.race([promise, guard]);
145
+ } finally {
146
+ clearTimeout(timer);
147
+ if (onAbort) signal.removeEventListener('abort', onAbort);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Open one MCP connection and discover its tools.
153
+ *
154
+ * @param {object} provider provider descriptor
155
+ * @param {object} options
156
+ * @param {number} options.timeoutMs
157
+ * @param {AbortSignal} [options.signal]
158
+ * @returns {Promise<{client: object, tools: object[]}>}
159
+ */
160
+ export async function connectProvider(provider, { timeoutMs = 8000, signal = null, logger = null } = {}) {
161
+ const client = new Client(CLIENT_INFO, { capabilities: {} });
162
+ let transport;
163
+ try {
164
+ if (provider.transport === 'stdio') {
165
+ transport = new StdioClientTransport({
166
+ command: provider.command,
167
+ args: provider.args || [],
168
+ env: safeChildEnv(provider.env),
169
+ cwd: provider.cwd || process.cwd(),
170
+ stderr: 'pipe',
171
+ });
172
+ } else {
173
+ transport = new StreamableHTTPClientTransport(new URL(provider.url), {
174
+ requestInit: { headers: provider.headers || {} },
175
+ });
176
+ }
177
+ await withTimeout(client.connect(transport), {
178
+ timeoutMs,
179
+ signal,
180
+ label: `${provider.label} handshake`,
181
+ });
182
+ } catch (error) {
183
+ await closeQuietly(client, transport);
184
+ throw mcpError(error, { provider, phase: 'handshake' });
185
+ }
186
+
187
+ try {
188
+ const listed = await withTimeout(client.listTools(undefined, { timeout: timeoutMs }), {
189
+ timeoutMs,
190
+ signal,
191
+ label: `${provider.label} tool discovery`,
192
+ });
193
+ const tools = Array.isArray(listed?.tools) ? listed.tools : [];
194
+ logger?.debug('mcp provider discovered', {
195
+ provider: provider.id,
196
+ tools: tools.map((tool) => tool.name).slice(0, 20),
197
+ });
198
+ return { client, tools };
199
+ } catch (error) {
200
+ await closeQuietly(client, transport);
201
+ throw mcpError(error, { provider, phase: 'discovery' });
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Call one discovered tool. The SDK validates the result envelope; the caller
207
+ * normalizes the payload.
208
+ */
209
+ export async function callTool(connection, { name, args, timeoutMs = 8000, signal = null }) {
210
+ return withTimeout(
211
+ connection.client.callTool({ name, arguments: args }, undefined, { timeout: timeoutMs, signal }),
212
+ { timeoutMs, signal, label: `${name}` },
213
+ );
214
+ }
215
+
216
+ async function closeQuietly(client, transport) {
217
+ try {
218
+ if (typeof client?.close === 'function') await client.close();
219
+ else if (typeof transport?.close === 'function') await transport.close();
220
+ } catch {
221
+ // Closing a broken transport is best-effort.
222
+ }
223
+ }
224
+
225
+ export async function closeConnection(connection) {
226
+ if (!connection) return;
227
+ try {
228
+ if (typeof connection.client?.close === 'function') await connection.client.close();
229
+ else if (typeof connection.transport?.close === 'function') await connection.transport.close();
230
+ } catch {
231
+ // best-effort
232
+ }
233
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Normalization for MCP tool results.
3
+ *
4
+ * Every provider speaks a different dialect: You.com nests results under
5
+ * `results.web`, Firecrawl under `data.web`, Tavily under `results`, and a
6
+ * community server may only return Markdown. The agent must never see those
7
+ * differences, so everything is reduced to:
8
+ *
9
+ * { provider, query, results: [{ title, url, snippet, content?, date? }] }
10
+ *
11
+ * Rules: only http(s) URLs without credentials, dedupe by canonical URL, cap
12
+ * counts and text length, and never invent a title, snippet or source.
13
+ */
14
+
15
+ import { SearchError } from '../../utils/errors.js';
16
+
17
+ const MAX_TITLE = 200;
18
+ const MAX_SNIPPET = 320;
19
+ const MAX_URL = 500;
20
+ const MAX_TEXT = 50000;
21
+
22
+ const clean = (value, max) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
23
+
24
+ /** Canonical URL for dedupe: lowercased host, no fragment, no trailing slash. */
25
+ export function canonicalUrl(value) {
26
+ try {
27
+ const url = new URL(String(value));
28
+ if (!['http:', 'https:'].includes(url.protocol)) return null;
29
+ if (url.username || url.password) return null;
30
+ url.hash = '';
31
+ if (url.pathname !== '/' && url.pathname.endsWith('/')) url.pathname = url.pathname.slice(0, -1);
32
+ return url.toString();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** Pull the structured payload out of an MCP call result (or null). */
39
+ export function extractPayload(callResult) {
40
+ if (!callResult || typeof callResult !== 'object') return null;
41
+ if (callResult.structuredContent && typeof callResult.structuredContent === 'object') {
42
+ return callResult.structuredContent;
43
+ }
44
+ for (const item of Array.isArray(callResult.content) ? callResult.content : []) {
45
+ if (item?.type !== 'text' || typeof item.text !== 'string' || !item.text.trim()) continue;
46
+ const text = item.text.trim();
47
+ try {
48
+ return JSON.parse(text);
49
+ } catch {
50
+ return text;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Collect candidate result arrays from the known provider dialects. */
57
+ function candidateLists(payload) {
58
+ if (!payload) return [];
59
+ if (Array.isArray(payload)) return [payload];
60
+ if (typeof payload !== 'object') return [];
61
+ const lists = [];
62
+ const push = (value) => {
63
+ if (Array.isArray(value)) lists.push(value);
64
+ };
65
+ push(payload.results);
66
+ push(payload.results?.web);
67
+ push(payload.results?.news);
68
+ push(payload.web);
69
+ push(payload.data?.web);
70
+ push(payload.data?.results);
71
+ push(payload.data);
72
+ push(payload.organic);
73
+ push(payload.organic_results);
74
+ push(payload.items);
75
+ push(payload.pages);
76
+ return lists;
77
+ }
78
+
79
+ function snippetOf(item) {
80
+ const direct = item.snippet || item.description || item.summary || item.text || item.content;
81
+ if (typeof direct === 'string' && direct.trim()) return clean(direct, MAX_SNIPPET);
82
+ const highlights = item.contents?.highlights || item.highlights;
83
+ if (Array.isArray(highlights)) return clean(highlights.join(' '), MAX_SNIPPET);
84
+ if (typeof item.markdown === 'string') return clean(item.markdown, MAX_SNIPPET);
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Normalize a search payload.
90
+ *
91
+ * @returns {{results: object[], answer_box: object|null, people_also_ask: object[], related_searches: string[], format: string}}
92
+ * @throws {SearchError} when the payload is not a recognizable shape
93
+ */
94
+ export function normalizeSearchPayload(payload, { maxResults = 5 } = {}) {
95
+ const limit = Math.max(1, Math.min(Number(maxResults) || 5, 10));
96
+ let raw = payload;
97
+
98
+ // JSON encoded inside a text item was already parsed by extractPayload; a
99
+ // plain string here is either Markdown links or unusable prose.
100
+ if (typeof raw === 'string') {
101
+ const results = parseMarkdownLinks(raw, limit);
102
+ if (results.length === 0) {
103
+ throw new SearchError('The search provider returned text without any recognizable results.', {
104
+ code: 'MCP_BAD_RESPONSE',
105
+ });
106
+ }
107
+ return {
108
+ results,
109
+ answer_box: null,
110
+ people_also_ask: [],
111
+ related_searches: [],
112
+ format: 'markdown',
113
+ };
114
+ }
115
+ if (!raw || typeof raw !== 'object') {
116
+ throw new SearchError('The search provider returned an empty or invalid response.', {
117
+ code: 'MCP_BAD_RESPONSE',
118
+ });
119
+ }
120
+ if (raw.error && !raw.results) {
121
+ throw new SearchError(`The search provider returned an error: ${clean(raw.error, 300)}`, {
122
+ code: 'MCP_PROVIDER_ERROR',
123
+ });
124
+ }
125
+
126
+ const lists = candidateLists(raw);
127
+ const seen = new Set();
128
+ const results = [];
129
+ for (const list of lists) {
130
+ for (const item of list) {
131
+ if (!item || typeof item !== 'object') continue;
132
+ const rawUrl = item.url || item.link || item.href;
133
+ const identity = canonicalUrl(rawUrl);
134
+ if (!identity || seen.has(identity)) continue;
135
+ const title = clean(item.title || item.name || item.heading, MAX_TITLE);
136
+ if (!title) continue;
137
+ const snippet = snippetOf(item);
138
+ const date = clean(item.date || item.publishedDate || item.published_date || item.metadata?.publishedDate, 40);
139
+ seen.add(identity);
140
+ results.push({
141
+ rank: results.length + 1,
142
+ title,
143
+ // Keep the provider's URL (only normalized by the URL parser); the
144
+ // canonical form is used for dedupe, not for rewriting the source.
145
+ url: sourceUrl(rawUrl, identity),
146
+ snippet: snippet || null,
147
+ ...(date ? { date } : {}),
148
+ });
149
+ if (results.length >= limit) break;
150
+ }
151
+ if (results.length >= limit) break;
152
+ }
153
+
154
+ if (results.length === 0 && lists.length === 0 && !Array.isArray(raw)) {
155
+ // A structured response with no list at all is a schema mismatch, not a
156
+ // valid "no results" answer. Let the manager fall back to the next provider.
157
+ const keys = Object.keys(raw).slice(0, 8).join(', ');
158
+ throw new SearchError(`The search provider response has an unknown shape (keys: ${keys || 'none'}).`, {
159
+ code: 'MCP_BAD_RESPONSE',
160
+ });
161
+ }
162
+
163
+ return {
164
+ results,
165
+ answer_box: normalizeAnswerBox(raw),
166
+ people_also_ask: normalizePeopleAlsoAsk(raw),
167
+ related_searches: normalizeRelated(raw),
168
+ format: 'json',
169
+ };
170
+ }
171
+
172
+ function normalizeAnswerBox(payload) {
173
+ const box = payload.answer_box || payload.answerBox;
174
+ if (!box || typeof box !== 'object') return null;
175
+ const text = clean(box.answer || box.text || box.snippet || box.description, MAX_SNIPPET);
176
+ if (!text) return null;
177
+ return {
178
+ ...(box.title ? { title: clean(box.title, MAX_TITLE) } : {}),
179
+ text,
180
+ ...(canonicalUrl(box.url || box.link) ? { url: canonicalUrl(box.url || box.link) } : {}),
181
+ };
182
+ }
183
+
184
+ function normalizePeopleAlsoAsk(payload) {
185
+ const list = payload.people_also_ask || payload.peopleAlsoAsk || payload.related_questions;
186
+ if (!Array.isArray(list)) return [];
187
+ return list
188
+ .map((item) => ({
189
+ question: clean(item?.question || item?.title, MAX_TITLE),
190
+ snippet: clean(item?.snippet || item?.answer || item?.description, MAX_SNIPPET) || null,
191
+ }))
192
+ .filter((item) => item.question)
193
+ .slice(0, 4);
194
+ }
195
+
196
+ function normalizeRelated(payload) {
197
+ const list = payload.related_searches || payload.relatedSearches;
198
+ if (!Array.isArray(list)) return [];
199
+ return list
200
+ .map((item) => clean(typeof item === 'string' ? item : item?.query, 160))
201
+ .filter(Boolean)
202
+ .slice(0, 5);
203
+ }
204
+
205
+ /** Parse `[title](url)` lines from text-only MCP servers. */
206
+ function parseMarkdownLinks(text, limit) {
207
+ const results = [];
208
+ const seen = new Set();
209
+ const pattern = /\[([^\]]{1,200})\]\((https?:\/\/[^\s)]{1,500})\)/g;
210
+ let match;
211
+ while ((match = pattern.exec(text)) && results.length < limit) {
212
+ const identity = canonicalUrl(match[2]);
213
+ if (!identity || seen.has(identity)) continue;
214
+ seen.add(identity);
215
+ const after = text.slice(pattern.lastIndex).split('\n')[0].replace(/^[\s:–—-]+/, '');
216
+ results.push({
217
+ rank: results.length + 1,
218
+ title: clean(match[1], MAX_TITLE),
219
+ url: sourceUrl(match[2], identity),
220
+ snippet: after ? clean(after, MAX_SNIPPET) : null,
221
+ });
222
+ }
223
+ return results;
224
+ }
225
+
226
+ /** Keep the provider's URL but reject anything the URL parser cannot read. */
227
+ function sourceUrl(value, fallback) {
228
+ try {
229
+ const parsed = new URL(String(value));
230
+ if (!['http:', 'https:'].includes(parsed.protocol)) return fallback;
231
+ return parsed.toString();
232
+ } catch {
233
+ return fallback;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Normalize an extraction result to `{title, text, url}`.
239
+ * Missing text is a valid empty extraction, not an error.
240
+ */
241
+ export function normalizeExtractPayload(payload, { url = null } = {}) {
242
+ const empty = { title: '', text: '', url };
243
+ if (typeof payload === 'string') {
244
+ return { ...empty, text: cleanText(payload) };
245
+ }
246
+ if (!payload || typeof payload !== 'object') return empty;
247
+ const data = payload.data && typeof payload.data === 'object' ? payload.data : payload;
248
+ const first = Array.isArray(data.results) ? data.results[0] : null;
249
+ const text =
250
+ firstString(
251
+ data.markdown,
252
+ data.content,
253
+ data.text,
254
+ data.rawContent,
255
+ data.raw_content,
256
+ payload.markdown,
257
+ payload.text,
258
+ first?.raw_content,
259
+ first?.rawContent,
260
+ first?.content,
261
+ first?.text,
262
+ ) || '';
263
+ const title = clean(data.title || data.metadata?.title || first?.title || payload.title, 300);
264
+ const finalUrl = canonicalUrl(data.url || data.metadata?.sourceURL || first?.url || url) || url;
265
+ return { title, text: cleanText(text), url: finalUrl };
266
+ }
267
+
268
+ function firstString(...values) {
269
+ for (const value of values) {
270
+ if (typeof value === 'string' && value.trim()) return value;
271
+ }
272
+ return '';
273
+ }
274
+
275
+ function cleanText(text) {
276
+ return String(text ?? '')
277
+ .replace(/\r\n?/g, '\n')
278
+ .replace(/[ \t]+$/gm, '')
279
+ .replace(/\n{4,}/g, '\n\n\n')
280
+ .trim()
281
+ .slice(0, MAX_TEXT);
282
+ }
283
+
284
+ export const NORMALIZE_LIMITS = { MAX_TITLE, MAX_SNIPPET, MAX_URL, MAX_TEXT };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Provider presets for web search.
3
+ *
4
+ * Endpoints and tool names were checked against the live servers (You.com and
5
+ * Firecrawl answered MCP discovery on 2026-10-23). Capabilities are *declared*
6
+ * here only as hints; the manager maps the tools that discovery actually
7
+ * returns, so a provider that changes its tool set keeps working.
8
+ *
9
+ * API keys are optional where the service allows keyless use. They are read
10
+ * from config.json and placed in request headers, never in a URL or log.
11
+ */
12
+
13
+ import { createSerperProvider } from './serper.js';
14
+
15
+ export const PROVIDER_IDS = ['serper', 'you', 'firecrawl', 'duckduckgo', 'tavily'];
16
+
17
+ export const DEFAULT_PROVIDER_ORDER = ['serper', 'you', 'firecrawl', 'duckduckgo', 'tavily'];
18
+
19
+ export const PROVIDER_INFO = {
20
+ serper: {
21
+ label: 'Serper',
22
+ transport: 'https',
23
+ requiresKey: true,
24
+ keySetting: 'webSearch.serperApiKey',
25
+ docs: 'https://serper.dev',
26
+ note: 'Serper-compatible SERP REST API (pre-existing backend).',
27
+ },
28
+ you: {
29
+ label: 'You.com',
30
+ transport: 'streamable-http',
31
+ requiresKey: false,
32
+ keySetting: 'webSearch.youApiKey',
33
+ docs: 'https://you.com/docs/agents/mcp-server',
34
+ note: 'Free profile is keyless and search-only; a key adds contents/discovery tools.',
35
+ },
36
+ firecrawl: {
37
+ label: 'Firecrawl',
38
+ transport: 'streamable-http',
39
+ requiresKey: false,
40
+ keySetting: 'webSearch.firecrawlApiKey',
41
+ docs: 'https://docs.firecrawl.dev/mcp-server/keyless',
42
+ note: 'Keyless search + scrape with a daily credit cap; a key raises limits.',
43
+ },
44
+ duckduckgo: {
45
+ label: 'DuckDuckGo',
46
+ transport: 'stdio',
47
+ requiresKey: false,
48
+ keySetting: null,
49
+ docs: 'https://github.com/sweetcornna/free-search-mcp',
50
+ note: 'Local community server (MIT). Disabled by default: it runs third-party code and needs uv/uvx.',
51
+ },
52
+ tavily: {
53
+ label: 'Tavily',
54
+ transport: 'streamable-http',
55
+ requiresKey: true,
56
+ keySetting: 'webSearch.tavilyApiKey',
57
+ docs: 'https://docs.tavily.com/documentation/mcp',
58
+ note: 'Agent-oriented search and extraction; API key required by the remote MCP endpoint.',
59
+ },
60
+ };
61
+
62
+ const trim = (value) => String(value ?? '').trim();
63
+
64
+ /** Build the provider descriptors in the configured order. */
65
+ export function buildProviders(config = {}, { fetch = null } = {}) {
66
+ const order = Array.isArray(config.webSearchProviders) && config.webSearchProviders.length > 0
67
+ ? config.webSearchProviders
68
+ : DEFAULT_PROVIDER_ORDER;
69
+ const disabled = new Set(Array.isArray(config.webSearchDisabled) ? config.webSearchDisabled : []);
70
+ const providers = [];
71
+
72
+ for (const id of order) {
73
+ if (!PROVIDER_IDS.includes(id)) continue;
74
+ if (id === 'serper') {
75
+ const provider = createSerperProvider({ config, fetch });
76
+ provider.enabled = !disabled.has('serper');
77
+ providers.push(provider);
78
+ continue;
79
+ }
80
+
81
+ if (id === 'you') {
82
+ const apiKey = trim(config.youApiKey);
83
+ providers.push({
84
+ id: 'you',
85
+ label: 'You.com',
86
+ kind: 'mcp',
87
+ transport: 'streamable-http',
88
+ url: apiKey ? 'https://api.you.com/mcp' : 'https://api.you.com/mcp?profile=free',
89
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
90
+ requiresKey: false,
91
+ configured: true,
92
+ enabled: !disabled.has('you'),
93
+ authenticated: Boolean(apiKey),
94
+ // With a key the default tool allowlist includes you-contents; without
95
+ // one the free profile is search-only (confirmed by discovery).
96
+ capabilities: { search: true, extract: Boolean(apiKey) },
97
+ keySetting: 'webSearch.youApiKey',
98
+ });
99
+ continue;
100
+ }
101
+
102
+ if (id === 'firecrawl') {
103
+ const apiKey = trim(config.firecrawlApiKey);
104
+ providers.push({
105
+ id: 'firecrawl',
106
+ label: 'Firecrawl',
107
+ kind: 'mcp',
108
+ transport: 'streamable-http',
109
+ url: 'https://mcp.firecrawl.dev/v2/mcp',
110
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
111
+ requiresKey: false,
112
+ configured: true,
113
+ enabled: !disabled.has('firecrawl'),
114
+ authenticated: Boolean(apiKey),
115
+ capabilities: { search: true, extract: true },
116
+ keySetting: 'webSearch.firecrawlApiKey',
117
+ });
118
+ continue;
119
+ }
120
+
121
+ if (id === 'duckduckgo') {
122
+ const command = trim(config.duckduckgoMcpCommand) || 'uvx';
123
+ const args = Array.isArray(config.duckduckgoMcpArgs) && config.duckduckgoMcpArgs.length > 0
124
+ ? config.duckduckgoMcpArgs
125
+ : ['free-search-mcp'];
126
+ providers.push({
127
+ id: 'duckduckgo',
128
+ label: 'DuckDuckGo',
129
+ kind: 'mcp',
130
+ transport: 'stdio',
131
+ command,
132
+ args,
133
+ env: {},
134
+ requiresKey: false,
135
+ configured: Boolean(command),
136
+ enabled: Boolean(config.duckduckgoMcpEnabled) && !disabled.has('duckduckgo'),
137
+ capabilities: { search: true, extract: true },
138
+ keySetting: null,
139
+ });
140
+ continue;
141
+ }
142
+
143
+ if (id === 'tavily') {
144
+ const apiKey = trim(config.tavilyApiKey);
145
+ providers.push({
146
+ id: 'tavily',
147
+ label: 'Tavily',
148
+ kind: 'mcp',
149
+ transport: 'streamable-http',
150
+ url: 'https://mcp.tavily.com/mcp/',
151
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
152
+ requiresKey: true,
153
+ configured: Boolean(apiKey),
154
+ enabled: !disabled.has('tavily'),
155
+ authenticated: Boolean(apiKey),
156
+ capabilities: { search: true, extract: true },
157
+ keySetting: 'webSearch.tavilyApiKey',
158
+ });
159
+ }
160
+ }
161
+
162
+ return providers;
163
+ }
164
+
165
+ /** Public, secret-free view of a provider for `/web` and logs. */
166
+ export function describeProvider(provider) {
167
+ return {
168
+ id: provider.id,
169
+ label: provider.label,
170
+ transport: provider.transport,
171
+ enabled: provider.enabled,
172
+ configured: provider.configured,
173
+ requiresKey: provider.requiresKey,
174
+ authenticated: Boolean(provider.authenticated),
175
+ capabilities: { ...provider.capabilities },
176
+ keySetting: provider.keySetting || null,
177
+ };
178
+ }