@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.4.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/CHANGELOG.md +592 -0
- package/LICENSE +21 -0
- package/README.md +1269 -2
- package/package.json +45 -4
- package/src/agent/agent.js +1194 -0
- package/src/agent/approvals.js +76 -0
- package/src/agent/context.js +106 -0
- package/src/agent/planner.js +237 -0
- package/src/agent/worklog.js +318 -0
- package/src/cli/app.js +471 -0
- package/src/cli/commands.js +1155 -0
- package/src/cli/input.js +287 -0
- package/src/cli/markdown.js +527 -0
- package/src/cli/prompts.js +255 -0
- package/src/cli/renderer.js +2155 -0
- package/src/cli/settings-editor.js +280 -0
- package/src/cli/settings-tui.js +669 -0
- package/src/cli/tui.js +1737 -0
- package/src/cli/width.js +48 -0
- package/src/config/load.js +137 -0
- package/src/config/scaffold.js +50 -0
- package/src/config/settings-registry.js +495 -0
- package/src/config/settings.js +304 -0
- package/src/config/validate.js +461 -0
- package/src/index.js +449 -0
- package/src/providers/catalog.js +407 -0
- package/src/providers/ids.js +24 -0
- package/src/providers/index.js +103 -0
- package/src/providers/nvidia.js +671 -0
- package/src/providers/registry.js +104 -0
- package/src/providers/river.js +198 -0
- package/src/providers/tokenharbor.js +38 -0
- package/src/providers/vendor-extras.js +82 -0
- package/src/services/clipboard.js +38 -0
- package/src/services/diff.js +153 -0
- package/src/services/doctor.js +126 -0
- package/src/services/logger.js +190 -0
- package/src/services/preferences.js +249 -0
- package/src/services/processes.js +345 -0
- package/src/services/provider-settings.js +226 -0
- package/src/services/sessions.js +279 -0
- package/src/services/undo.js +133 -0
- package/src/services/ushell.js +146 -0
- package/src/services/websearch/manager.js +616 -0
- package/src/services/websearch/mcp.js +233 -0
- package/src/services/websearch/normalize.js +284 -0
- package/src/services/websearch/providers.js +178 -0
- package/src/services/websearch/serper.js +263 -0
- package/src/services/workspace.js +589 -0
- package/src/tools/filesystem.js +304 -0
- package/src/tools/git.js +114 -0
- package/src/tools/index.js +48 -0
- package/src/tools/project.js +160 -0
- package/src/tools/registry.js +118 -0
- package/src/tools/search.js +210 -0
- package/src/tools/shell.js +279 -0
- package/src/tools/url.js +542 -0
- package/src/tools/web.js +67 -0
- package/src/utils/errors.js +160 -0
- package/src/utils/network.js +129 -0
- package/src/utils/redaction.js +73 -0
- package/src/utils/validation.js +370 -0
- package/src/utils/version.js +24 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSearchManager — one search surface over several MCP providers.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - lazy connect + tool discovery per provider (no startup penalty);
|
|
6
|
+
* - map discovered tools to capabilities (search / extract) by name,
|
|
7
|
+
* description and input schema, never by a hard-coded tool name;
|
|
8
|
+
* - try providers in the configured order, with bounded retries and a
|
|
9
|
+
* temporary circuit breaker for repeated failures;
|
|
10
|
+
* - normalize every provider's payload to the same result shape;
|
|
11
|
+
* - report failures through typed errors and UI events without leaking keys.
|
|
12
|
+
*
|
|
13
|
+
* Fallback is deliberately not blind: an authentication/config error or a
|
|
14
|
+
* provider without the requested capability is skipped immediately; empty but
|
|
15
|
+
* valid results are recorded and the next provider may still be consulted.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { CancelledError, SearchError, ValidationError } from '../../utils/errors.js';
|
|
19
|
+
import { assertPublicUrl } from '../../utils/network.js';
|
|
20
|
+
import { buildProviders, describeProvider } from './providers.js';
|
|
21
|
+
import { callTool as defaultCallTool, closeConnection as defaultClose, connectProvider } from './mcp.js';
|
|
22
|
+
import { extractPayload, normalizeExtractPayload, normalizeSearchPayload } from './normalize.js';
|
|
23
|
+
|
|
24
|
+
const MAX_QUERY_CHARS = 400;
|
|
25
|
+
const BREAKER_THRESHOLD = 2;
|
|
26
|
+
const BREAKER_COOLDOWN_MS = 60_000;
|
|
27
|
+
const MAX_REASON = 90;
|
|
28
|
+
|
|
29
|
+
const clean = (value, max) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
30
|
+
|
|
31
|
+
/** Score every discovered tool and return the best fit for a capability. */
|
|
32
|
+
export function pickTool(tools, capability) {
|
|
33
|
+
let best = null;
|
|
34
|
+
let bestScore = 0;
|
|
35
|
+
for (const tool of Array.isArray(tools) ? tools : []) {
|
|
36
|
+
const name = String(tool?.name || '').toLowerCase();
|
|
37
|
+
if (!name) continue;
|
|
38
|
+
const description = String(tool?.description || '').toLowerCase();
|
|
39
|
+
const props = tool?.inputSchema?.properties || {};
|
|
40
|
+
const hasQuery = Boolean(props.query || props.q || props.search_query);
|
|
41
|
+
const hasUrl = Boolean(props.url || props.urls || props.link);
|
|
42
|
+
let score = 0;
|
|
43
|
+
if (capability === 'search') {
|
|
44
|
+
if (/search|serp/.test(name)) score += 10;
|
|
45
|
+
else if (/search|serp/.test(description)) score += 4;
|
|
46
|
+
if (hasQuery) score += 3;
|
|
47
|
+
if (/scrape|extract|contents?|fetch|read|crawl/.test(name)) score -= 8;
|
|
48
|
+
} else {
|
|
49
|
+
if (/scrape|extract|contents?|fetch|markdown|read/.test(name)) score += 8;
|
|
50
|
+
if (hasUrl) score += 4;
|
|
51
|
+
if (/search|serp/.test(name)) score -= 8;
|
|
52
|
+
}
|
|
53
|
+
if (score > bestScore) {
|
|
54
|
+
best = tool;
|
|
55
|
+
bestScore = score;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return bestScore > 0 ? best : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Fill the tool's own schema instead of assuming one provider's argument names. */
|
|
62
|
+
export function buildToolArgs(tool, capability, input) {
|
|
63
|
+
const props = tool?.inputSchema?.properties || {};
|
|
64
|
+
const required = new Set(tool?.inputSchema?.required || []);
|
|
65
|
+
const args = {};
|
|
66
|
+
if (capability === 'search') {
|
|
67
|
+
if (props.query) args.query = input.query;
|
|
68
|
+
else if (props.q) args.q = input.query;
|
|
69
|
+
else if (props.search_query) args.search_query = input.query;
|
|
70
|
+
for (const key of ['limit', 'numResults', 'max_results', 'maxResults', 'count', 'num']) {
|
|
71
|
+
if (props[key]) args[key] = input.maxResults;
|
|
72
|
+
}
|
|
73
|
+
if (props.depth) args.depth = input.depth;
|
|
74
|
+
// Firecrawl's MCP schema requires a short statement of intent. It is
|
|
75
|
+
// derived from the query, never invented as a search result.
|
|
76
|
+
if (props.objective) {
|
|
77
|
+
args.objective = String(input.objective || `Find current information about: ${input.query}`).slice(0, 1000);
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
if (props.url) args.url = input.url;
|
|
81
|
+
else if (props.urls) args.urls = [input.url];
|
|
82
|
+
else if (props.link) args.link = input.url;
|
|
83
|
+
if (props.formats) args.formats = ['markdown'];
|
|
84
|
+
if (props.objective) {
|
|
85
|
+
args.objective = String(input.objective || `Extract the readable content of ${input.url} for the agent.`).slice(0, 1000);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const key of required) {
|
|
89
|
+
if (args[key] === undefined) {
|
|
90
|
+
throw new SearchError(`The provider tool ${tool?.name || 'unknown'} requires an unsupported argument "${key}".`, {
|
|
91
|
+
code: 'MCP_ARGUMENTS',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return args;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function failureReason(error) {
|
|
99
|
+
const code = error?.code || '';
|
|
100
|
+
if (code === 'MCP_AUTH' || code === 'SEARCH_AUTH') return 'autentikasi gagal';
|
|
101
|
+
if (code === 'MCP_RATE_LIMIT' || code === 'SEARCH_RATE_LIMIT') return 'rate limit';
|
|
102
|
+
if (code === 'MCP_TIMEOUT' || code === 'SEARCH_TIMEOUT') return 'timeout';
|
|
103
|
+
if (code === 'MCP_NETWORK' || code === 'SEARCH_NETWORK') return 'tidak dapat dihubungi';
|
|
104
|
+
if (code === 'MCP_HANDSHAKE') return 'handshake MCP gagal';
|
|
105
|
+
if (code === 'MCP_DISCOVERY') return 'tool discovery gagal';
|
|
106
|
+
if (code === 'MCP_TOOL_MISSING') return 'tool tidak tersedia';
|
|
107
|
+
if (code === 'MCP_ARGUMENTS') return 'skema argumen tidak cocok';
|
|
108
|
+
if (code === 'MCP_BAD_RESPONSE') return 'respons tidak valid';
|
|
109
|
+
if (code === 'MCP_PROVIDER_ERROR') return 'provider error';
|
|
110
|
+
if (code === 'MCP_SPAWN') return 'gagal dijalankan';
|
|
111
|
+
if (code === 'MCP_EMPTY_EXTRACT') return 'halaman kosong';
|
|
112
|
+
if (error?.retryable) return clean(error.message, MAX_REASON);
|
|
113
|
+
return clean(error?.message || 'gagal', MAX_REASON);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class WebSearchManager {
|
|
117
|
+
constructor({
|
|
118
|
+
config = {},
|
|
119
|
+
logger = null,
|
|
120
|
+
onEvent = null,
|
|
121
|
+
connect = connectProvider,
|
|
122
|
+
callTool = defaultCallTool,
|
|
123
|
+
closeConnection = defaultClose,
|
|
124
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
125
|
+
now = () => Date.now(),
|
|
126
|
+
providers = null,
|
|
127
|
+
fetch = null,
|
|
128
|
+
} = {}) {
|
|
129
|
+
this.config = config;
|
|
130
|
+
this.logger = logger;
|
|
131
|
+
this.onEvent = onEvent;
|
|
132
|
+
this.connect = connect;
|
|
133
|
+
this.callToolImpl = callTool;
|
|
134
|
+
this.closeConnection = closeConnection;
|
|
135
|
+
this.sleep = sleep;
|
|
136
|
+
this.now = now;
|
|
137
|
+
this.enabled = config.webSearchEnabled !== false;
|
|
138
|
+
this.fallbackEnabled = config.webSearchFallback !== false;
|
|
139
|
+
this.timeoutMs = Math.max(1000, Number(config.webMcpTimeoutMs) || 8000);
|
|
140
|
+
this.maxRetries = Math.max(0, Math.min(Number(config.webMcpMaxRetries) || 0, 5));
|
|
141
|
+
this.maxResults = Math.max(1, Math.min(Number(config.webSearchResults) || 5, 10));
|
|
142
|
+
this.providers = providers || buildProviders(config, { fetch });
|
|
143
|
+
this.enabledOverrides = new Map();
|
|
144
|
+
this.connections = new Map();
|
|
145
|
+
this.pending = new Map();
|
|
146
|
+
this.breakers = new Map();
|
|
147
|
+
this.stats = new Map();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Providers in the configured order, including disabled/unconfigured ones. */
|
|
151
|
+
list() {
|
|
152
|
+
return this.providers.map((provider) => {
|
|
153
|
+
const stat = this.stats.get(provider.id) || {};
|
|
154
|
+
const breaker = this.breakers.get(provider.id);
|
|
155
|
+
return {
|
|
156
|
+
...describeProvider(provider),
|
|
157
|
+
enabled: this.isEnabled(provider),
|
|
158
|
+
...(provider.endpoint ? { endpoint: provider.endpoint } : {}),
|
|
159
|
+
connected: this.connections.has(provider.id),
|
|
160
|
+
lastStatus: stat.status || null,
|
|
161
|
+
lastReason: stat.reason || null,
|
|
162
|
+
lastDurationMs: stat.durationMs ?? null,
|
|
163
|
+
circuitOpen: Boolean(breaker?.openUntil && breaker.openUntil > this.now()),
|
|
164
|
+
circuitRetryInMs: breaker?.openUntil && breaker.openUntil > this.now()
|
|
165
|
+
? breaker.openUntil - this.now()
|
|
166
|
+
: 0,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
isEnabled(provider) {
|
|
172
|
+
if (this.enabledOverrides.has(provider.id)) return this.enabledOverrides.get(provider.id);
|
|
173
|
+
return provider.enabled !== false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Session-scoped enable/disable; environment config is not modified. */
|
|
177
|
+
setEnabled(id, enabled) {
|
|
178
|
+
const provider = this.providers.find((candidate) => candidate.id === id);
|
|
179
|
+
if (!provider) return false;
|
|
180
|
+
this.enabledOverrides.set(id, Boolean(enabled));
|
|
181
|
+
if (!enabled) this.#disconnect(id);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
hasProviders() {
|
|
186
|
+
if (!this.enabled) return false;
|
|
187
|
+
return this.providers.some((provider) => this.isEnabled(provider) && provider.configured);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
supports(capability) {
|
|
191
|
+
if (!this.enabled) return false;
|
|
192
|
+
return this.providers.some(
|
|
193
|
+
(provider) =>
|
|
194
|
+
this.isEnabled(provider) &&
|
|
195
|
+
provider.configured &&
|
|
196
|
+
(provider.capabilities?.[capability] ||
|
|
197
|
+
[...this.connections.values()].some((connection) => connection.providerId === provider.id && connection.capabilities?.[capability])),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#emit(event) {
|
|
202
|
+
if (!this.onEvent) return;
|
|
203
|
+
try {
|
|
204
|
+
this.onEvent({ type: 'webSearch', ...event });
|
|
205
|
+
} catch {
|
|
206
|
+
// A UI failure must never break the search path.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#record(provider, status, reason, durationMs) {
|
|
211
|
+
this.stats.set(provider.id, { status, reason, durationMs, at: new Date().toISOString() });
|
|
212
|
+
this.logger?.debug('web search provider', {
|
|
213
|
+
provider: provider.id,
|
|
214
|
+
status,
|
|
215
|
+
reason,
|
|
216
|
+
durationMs,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#breakerOpen(provider) {
|
|
221
|
+
const breaker = this.breakers.get(provider.id);
|
|
222
|
+
if (!breaker?.openUntil) return false;
|
|
223
|
+
if (this.now() >= breaker.openUntil) {
|
|
224
|
+
this.breakers.delete(provider.id);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#breakerFailure(provider) {
|
|
231
|
+
const current = this.breakers.get(provider.id) || { failures: 0, openUntil: 0 };
|
|
232
|
+
const failures = current.failures + 1;
|
|
233
|
+
this.breakers.set(provider.id, {
|
|
234
|
+
failures,
|
|
235
|
+
openUntil: failures >= BREAKER_THRESHOLD ? this.now() + BREAKER_COOLDOWN_MS : 0,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#breakerSuccess(provider) {
|
|
240
|
+
this.breakers.delete(provider.id);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async #ensureConnection(provider, signal) {
|
|
244
|
+
const existing = this.connections.get(provider.id);
|
|
245
|
+
if (existing) return existing;
|
|
246
|
+
const inflight = this.pending.get(provider.id);
|
|
247
|
+
if (inflight) return inflight;
|
|
248
|
+
|
|
249
|
+
const promise = (async () => {
|
|
250
|
+
const connection = await this.connect(provider, { timeoutMs: this.timeoutMs, signal, logger: this.logger });
|
|
251
|
+
const tools = Array.isArray(connection.tools) ? connection.tools : [];
|
|
252
|
+
const mapped = {
|
|
253
|
+
...connection,
|
|
254
|
+
providerId: provider.id,
|
|
255
|
+
tools,
|
|
256
|
+
capabilities: {
|
|
257
|
+
search: Boolean(pickTool(tools, 'search')),
|
|
258
|
+
extract: Boolean(pickTool(tools, 'extract')),
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
this.connections.set(provider.id, mapped);
|
|
262
|
+
return mapped;
|
|
263
|
+
})();
|
|
264
|
+
this.pending.set(provider.id, promise);
|
|
265
|
+
try {
|
|
266
|
+
return await promise;
|
|
267
|
+
} finally {
|
|
268
|
+
this.pending.delete(provider.id);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async #executeOnProvider(provider, capability, input, { signal }) {
|
|
273
|
+
if (provider.kind === 'rest') {
|
|
274
|
+
if (capability !== 'search' || typeof provider.search !== 'function') {
|
|
275
|
+
throw new SearchError(`${provider.label} does not support ${capability}.`, { code: 'MCP_TOOL_MISSING' });
|
|
276
|
+
}
|
|
277
|
+
return provider.search(input, { signal });
|
|
278
|
+
}
|
|
279
|
+
const connection = await this.#ensureConnection(provider, signal);
|
|
280
|
+
const tool = pickTool(connection.tools, capability);
|
|
281
|
+
if (!tool) {
|
|
282
|
+
throw new SearchError(`${provider.label} does not offer a ${capability} tool.`, {
|
|
283
|
+
code: 'MCP_TOOL_MISSING',
|
|
284
|
+
details: { provider: provider.id, capability },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const args = buildToolArgs(tool, capability, input);
|
|
288
|
+
let lastError;
|
|
289
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
|
|
290
|
+
if (signal?.aborted) throw new CancelledError('Task cancelled by user.');
|
|
291
|
+
try {
|
|
292
|
+
const result = await this.callToolImpl(connection, {
|
|
293
|
+
name: tool.name,
|
|
294
|
+
args,
|
|
295
|
+
timeoutMs: this.timeoutMs,
|
|
296
|
+
signal,
|
|
297
|
+
});
|
|
298
|
+
// MCP servers report tool-level failures in-band (isError) instead of
|
|
299
|
+
// as a transport error. Surface them as typed failures so fallback and
|
|
300
|
+
// the UI do not mistake them for an empty result.
|
|
301
|
+
if (result?.isError) {
|
|
302
|
+
const detail = typeof extractPayload(result) === 'string' ? extractPayload(result) : 'MCP tool error';
|
|
303
|
+
const status = Number(String(detail).match(/\b(401|403|429|5\d{2})\b/)?.[1]) || null;
|
|
304
|
+
const code = status === 401 || status === 403
|
|
305
|
+
? 'MCP_AUTH'
|
|
306
|
+
: status === 429
|
|
307
|
+
? 'MCP_RATE_LIMIT'
|
|
308
|
+
: status >= 500
|
|
309
|
+
? 'MCP_UPSTREAM'
|
|
310
|
+
: 'MCP_PROVIDER_ERROR';
|
|
311
|
+
throw new SearchError(`${provider.label} returned an error: ${clean(detail, 200)}`, {
|
|
312
|
+
code,
|
|
313
|
+
status,
|
|
314
|
+
retryable: code === 'MCP_RATE_LIMIT' || code === 'MCP_UPSTREAM',
|
|
315
|
+
details: { provider: provider.id },
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return result;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
lastError = error;
|
|
321
|
+
if (signal?.aborted || error?.code === 'CANCELLED') throw new CancelledError('Task cancelled by user.');
|
|
322
|
+
if (!error?.retryable || attempt === this.maxRetries) break;
|
|
323
|
+
// A rate limit is better served by the next provider than by waiting.
|
|
324
|
+
if (error.status === 429) break;
|
|
325
|
+
const delay = Math.min(2000, 250 * 2 ** attempt) + Math.floor(Math.random() * 100);
|
|
326
|
+
this.logger?.debug('retrying provider', { provider: provider.id, attempt: attempt + 1, delay });
|
|
327
|
+
await this.sleep(delay);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
throw lastError;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#candidates(capability) {
|
|
334
|
+
return this.providers.filter(
|
|
335
|
+
(provider) =>
|
|
336
|
+
this.isEnabled(provider) &&
|
|
337
|
+
provider.configured &&
|
|
338
|
+
(provider.capabilities?.[capability] || this.connections.get(provider.id)?.capabilities?.[capability]),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#rows(attempts) {
|
|
343
|
+
return this.providers.map((provider) => {
|
|
344
|
+
const attempt = attempts.find((entry) => entry.id === provider.id);
|
|
345
|
+
if (attempt) return attempt;
|
|
346
|
+
if (!this.isEnabled(provider)) return { id: provider.id, label: provider.label, status: 'skipped', reason: 'dinonaktifkan' };
|
|
347
|
+
if (!provider.configured) return { id: provider.id, label: provider.label, status: 'skipped', reason: 'API key belum dikonfigurasi' };
|
|
348
|
+
return { id: provider.id, label: provider.label, status: 'standby', reason: 'menunggu' };
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#allFailed(capability, attempts, query) {
|
|
353
|
+
const summary = attempts
|
|
354
|
+
.filter((attempt) => attempt.status === 'failed' || attempt.status === 'skipped' || attempt.status === 'empty')
|
|
355
|
+
.map((attempt) => `${attempt.label}: ${attempt.reason}`)
|
|
356
|
+
.join('; ');
|
|
357
|
+
return new SearchError(
|
|
358
|
+
`No web search provider succeeded${summary ? ` (${summary})` : ''}. Continue without web search or retry later.`,
|
|
359
|
+
{
|
|
360
|
+
code: capability === 'extract' ? 'SEARCH_EXTRACT_FAILED' : 'SEARCH_ALL_PROVIDERS_FAILED',
|
|
361
|
+
details: { attempts, query: query ? clean(query, 120) : undefined },
|
|
362
|
+
},
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async search({ query, depth = 'standard' } = {}, { signal = null } = {}) {
|
|
367
|
+
const text = clean(query, MAX_QUERY_CHARS);
|
|
368
|
+
if (!text) throw new ValidationError('query must be a non-empty string.');
|
|
369
|
+
if (!this.enabled) {
|
|
370
|
+
throw new SearchError('Web search is disabled (webSearch.enabled=false).', { code: 'SEARCH_DISABLED' });
|
|
371
|
+
}
|
|
372
|
+
const mode = depth === 'deep' ? 'deep' : 'standard';
|
|
373
|
+
const maxResults = mode === 'deep' ? Math.min(this.maxResults * 2, 10) : this.maxResults;
|
|
374
|
+
const candidates = this.#candidates('search');
|
|
375
|
+
const attempts = [];
|
|
376
|
+
const startedAt = this.now();
|
|
377
|
+
let lastOriginal = null;
|
|
378
|
+
let lastEmpty = null;
|
|
379
|
+
|
|
380
|
+
if (candidates.length === 0) {
|
|
381
|
+
const skipped = this.#rows([]).map((row) => ({
|
|
382
|
+
...row,
|
|
383
|
+
status: 'skipped',
|
|
384
|
+
reason: row.reason === 'menunggu' ? 'tidak tersedia' : row.reason,
|
|
385
|
+
}));
|
|
386
|
+
throw this.#allFailed('search', skipped, text);
|
|
387
|
+
}
|
|
388
|
+
const plan = this.fallbackEnabled ? candidates : candidates.slice(0, 1);
|
|
389
|
+
|
|
390
|
+
this.#emit({
|
|
391
|
+
phase: 'start',
|
|
392
|
+
capability: 'search',
|
|
393
|
+
query: text,
|
|
394
|
+
providers: this.list().map((provider) => ({ id: provider.id, label: provider.label, enabled: provider.enabled, configured: provider.configured })),
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
for (const provider of plan) {
|
|
398
|
+
if (signal?.aborted) throw new CancelledError('Task cancelled by user.');
|
|
399
|
+
if (this.#breakerOpen(provider)) {
|
|
400
|
+
attempts.push({ id: provider.id, label: provider.label, status: 'skipped', reason: 'circuit breaker terbuka' });
|
|
401
|
+
this.#record(provider, 'skipped', 'circuit breaker terbuka');
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
this.#emit({ phase: 'attempt', capability: 'search', provider: provider.id, label: provider.label });
|
|
405
|
+
const started = this.now();
|
|
406
|
+
try {
|
|
407
|
+
const result = await this.#executeOnProvider(provider, 'search', { query: text, depth: mode, maxResults }, { signal });
|
|
408
|
+
const durationMs = this.now() - started;
|
|
409
|
+
// REST providers already return the internal shape; MCP providers
|
|
410
|
+
// return a protocol envelope that must be decoded and normalized.
|
|
411
|
+
const normalized = provider.kind === 'rest'
|
|
412
|
+
? {
|
|
413
|
+
results: Array.isArray(result?.results) ? result.results : [],
|
|
414
|
+
answer_box: result?.answer_box || null,
|
|
415
|
+
people_also_ask: result?.people_also_ask || [],
|
|
416
|
+
related_searches: result?.related_searches || [],
|
|
417
|
+
}
|
|
418
|
+
: normalizeSearchPayload(extractPayload(result), { maxResults });
|
|
419
|
+
if (normalized.results.length === 0) {
|
|
420
|
+
if (provider.kind === 'rest') lastEmpty = { provider, result };
|
|
421
|
+
attempts.push({ id: provider.id, label: provider.label, status: 'empty', reason: 'tidak ada hasil', durationMs });
|
|
422
|
+
this.#record(provider, 'empty', 'tidak ada hasil', durationMs);
|
|
423
|
+
this.#emit({ phase: 'attempt', capability: 'search', provider: provider.id, status: 'empty', durationMs });
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
this.#breakerSuccess(provider);
|
|
427
|
+
this.#record(provider, 'ok', `${normalized.results.length} hasil`, durationMs);
|
|
428
|
+
attempts.push({ id: provider.id, label: provider.label, status: 'ok', reason: `${normalized.results.length} hasil`, durationMs });
|
|
429
|
+
const output = {
|
|
430
|
+
...(provider.kind === 'rest' ? result : {}),
|
|
431
|
+
query: text,
|
|
432
|
+
depth: mode,
|
|
433
|
+
provider: provider.id,
|
|
434
|
+
provider_label: provider.label,
|
|
435
|
+
result_count: normalized.results.length,
|
|
436
|
+
results: normalized.results,
|
|
437
|
+
answer_box: normalized.answer_box,
|
|
438
|
+
people_also_ask: normalized.people_also_ask,
|
|
439
|
+
related_searches: normalized.related_searches,
|
|
440
|
+
fallback_used: attempts.filter((attempt) => attempt.status === 'failed').length > 0,
|
|
441
|
+
cached: provider.kind === 'rest' ? Boolean(result?.cached) : false,
|
|
442
|
+
elapsed_ms: provider.kind === 'rest' && result?.cached ? 0 : this.now() - startedAt,
|
|
443
|
+
fetched_at: new Date().toISOString(),
|
|
444
|
+
};
|
|
445
|
+
this.#emit({ phase: 'done', capability: 'search', ok: true, provider: provider.id, rows: this.#rows(attempts), elapsedMs: output.elapsed_ms });
|
|
446
|
+
return output;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (error?.code === 'CANCELLED' || signal?.aborted) throw new CancelledError('Task cancelled by user.');
|
|
449
|
+
const durationMs = this.now() - started;
|
|
450
|
+
const reason = failureReason(error);
|
|
451
|
+
lastOriginal = error;
|
|
452
|
+
attempts.push({ id: provider.id, label: provider.label, status: 'failed', reason, durationMs });
|
|
453
|
+
this.#record(provider, 'failed', reason, durationMs);
|
|
454
|
+
this.#breakerFailure(provider);
|
|
455
|
+
this.#emit({ phase: 'attempt', capability: 'search', provider: provider.id, status: 'failed', reason, durationMs });
|
|
456
|
+
this.logger?.warn('web search provider failed', {
|
|
457
|
+
provider: provider.id,
|
|
458
|
+
code: error?.code || null,
|
|
459
|
+
reason,
|
|
460
|
+
});
|
|
461
|
+
if (!this.fallbackEnabled) break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const failures = attempts.filter((attempt) => attempt.status === 'failed');
|
|
466
|
+
const rows = this.#rows(attempts);
|
|
467
|
+
if (failures.length === 0 && attempts.some((attempt) => attempt.status === 'empty')) {
|
|
468
|
+
// Every provider answered, just with nothing. Not a provider failure.
|
|
469
|
+
const emptyProvider = lastEmpty?.provider?.id || null;
|
|
470
|
+
this.#emit({ phase: 'done', capability: 'search', ok: true, provider: emptyProvider, rows, elapsedMs: this.now() - startedAt });
|
|
471
|
+
return {
|
|
472
|
+
query: text,
|
|
473
|
+
depth: mode,
|
|
474
|
+
provider: emptyProvider,
|
|
475
|
+
provider_label: lastEmpty?.provider?.label || null,
|
|
476
|
+
...(lastEmpty?.provider?.kind === 'rest' ? lastEmpty.result : {}),
|
|
477
|
+
result_count: 0,
|
|
478
|
+
results: [],
|
|
479
|
+
answer_box: lastEmpty?.result?.answer_box || null,
|
|
480
|
+
people_also_ask: lastEmpty?.result?.people_also_ask || [],
|
|
481
|
+
related_searches: lastEmpty?.result?.related_searches || [],
|
|
482
|
+
fallback_used: attempts.length > 1,
|
|
483
|
+
cached: Boolean(lastEmpty?.result?.cached),
|
|
484
|
+
elapsed_ms: this.now() - startedAt,
|
|
485
|
+
fetched_at: new Date().toISOString(),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
this.#emit({ phase: 'done', capability: 'search', ok: false, provider: null, rows, elapsedMs: this.now() - startedAt });
|
|
489
|
+
// With one attempted provider, its own actionable error is more useful
|
|
490
|
+
// than a generic "all providers failed" wrapper.
|
|
491
|
+
if (lastOriginal && (plan.length === 1 || !this.fallbackEnabled)) {
|
|
492
|
+
if (lastOriginal instanceof Error) lastOriginal.details = { ...(lastOriginal.details || {}), attempts };
|
|
493
|
+
throw lastOriginal;
|
|
494
|
+
}
|
|
495
|
+
throw this.#allFailed('search', attempts, text);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async extract({ url, maxChars = 20000 } = {}, { signal = null } = {}) {
|
|
499
|
+
if (!this.enabled) {
|
|
500
|
+
throw new SearchError('Web search is disabled (webSearch.enabled=false).', { code: 'SEARCH_DISABLED' });
|
|
501
|
+
}
|
|
502
|
+
let parsed;
|
|
503
|
+
try {
|
|
504
|
+
parsed = new URL(String(url));
|
|
505
|
+
} catch {
|
|
506
|
+
throw new ValidationError('url must be a valid absolute URL.');
|
|
507
|
+
}
|
|
508
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
|
509
|
+
throw new ValidationError('url must be an http(s) URL without credentials.');
|
|
510
|
+
}
|
|
511
|
+
// The provider fetches remotely, but a local/metadata hostname must never
|
|
512
|
+
// be handed to it either.
|
|
513
|
+
await assertPublicUrl(parsed, { resolve: false });
|
|
514
|
+
const target = parsed.toString();
|
|
515
|
+
const candidates = this.#candidates('extract');
|
|
516
|
+
const attempts = [];
|
|
517
|
+
if (candidates.length === 0) {
|
|
518
|
+
throw new SearchError('No configured provider offers page extraction.', { code: 'SEARCH_NO_EXTRACT' });
|
|
519
|
+
}
|
|
520
|
+
const plan = this.fallbackEnabled ? candidates : candidates.slice(0, 1);
|
|
521
|
+
|
|
522
|
+
for (const provider of plan) {
|
|
523
|
+
if (signal?.aborted) throw new CancelledError('Task cancelled by user.');
|
|
524
|
+
if (this.#breakerOpen(provider)) {
|
|
525
|
+
attempts.push({ id: provider.id, label: provider.label, status: 'skipped', reason: 'circuit breaker terbuka' });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const started = this.now();
|
|
529
|
+
try {
|
|
530
|
+
const result = await this.#executeOnProvider(provider, 'extract', { url: target }, { signal });
|
|
531
|
+
const normalized = normalizeExtractPayload(extractPayload(result), { url: target });
|
|
532
|
+
const durationMs = this.now() - started;
|
|
533
|
+
if (!normalized.text) {
|
|
534
|
+
attempts.push({ id: provider.id, label: provider.label, status: 'empty', reason: 'halaman kosong', durationMs });
|
|
535
|
+
this.#record(provider, 'empty', 'halaman kosong', durationMs);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
this.#breakerSuccess(provider);
|
|
539
|
+
this.#record(provider, 'ok', `${normalized.text.length} chars`, durationMs);
|
|
540
|
+
this.#emit({ phase: 'done', capability: 'extract', ok: true, provider: provider.id, rows: this.#rows(attempts), elapsedMs: durationMs });
|
|
541
|
+
return {
|
|
542
|
+
url: normalized.url || target,
|
|
543
|
+
title: normalized.title,
|
|
544
|
+
text: normalized.text.slice(0, Math.max(1000, Math.min(Number(maxChars) || 20000, 50000))),
|
|
545
|
+
provider: provider.id,
|
|
546
|
+
fetched_at: new Date().toISOString(),
|
|
547
|
+
};
|
|
548
|
+
} catch (error) {
|
|
549
|
+
if (error?.code === 'CANCELLED' || signal?.aborted) throw new CancelledError('Task cancelled by user.');
|
|
550
|
+
const durationMs = this.now() - started;
|
|
551
|
+
const reason = failureReason(error);
|
|
552
|
+
attempts.push({ id: provider.id, label: provider.label, status: 'failed', reason, durationMs });
|
|
553
|
+
this.#record(provider, 'failed', reason, durationMs);
|
|
554
|
+
this.#breakerFailure(provider);
|
|
555
|
+
this.logger?.warn('page extraction failed', { provider: provider.id, code: error?.code || null, reason });
|
|
556
|
+
if (!this.fallbackEnabled) break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
throw this.#allFailed('extract', attempts, null);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Connection + discovery check only; never runs a billable query. */
|
|
563
|
+
async probe() {
|
|
564
|
+
const rows = [];
|
|
565
|
+
for (const provider of this.providers) {
|
|
566
|
+
if (!this.isEnabled(provider)) {
|
|
567
|
+
rows.push({ id: provider.id, label: provider.label, ok: false, skipped: true, reason: 'dinonaktifkan' });
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (!provider.configured) {
|
|
571
|
+
rows.push({ id: provider.id, label: provider.label, ok: false, skipped: true, reason: 'API key belum dikonfigurasi' });
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const started = this.now();
|
|
575
|
+
try {
|
|
576
|
+
const connection = await this.#ensureConnection(provider, null);
|
|
577
|
+
rows.push({
|
|
578
|
+
id: provider.id,
|
|
579
|
+
label: provider.label,
|
|
580
|
+
ok: true,
|
|
581
|
+
durationMs: this.now() - started,
|
|
582
|
+
tools: connection.tools.map((tool) => tool.name).slice(0, 12),
|
|
583
|
+
capabilities: { ...connection.capabilities },
|
|
584
|
+
});
|
|
585
|
+
} catch (error) {
|
|
586
|
+
rows.push({
|
|
587
|
+
id: provider.id,
|
|
588
|
+
label: provider.label,
|
|
589
|
+
ok: false,
|
|
590
|
+
durationMs: this.now() - started,
|
|
591
|
+
reason: failureReason(error),
|
|
592
|
+
code: error?.code || null,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return rows;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
#disconnect(id) {
|
|
600
|
+
const connection = this.connections.get(id);
|
|
601
|
+
if (!connection) return;
|
|
602
|
+
this.connections.delete(id);
|
|
603
|
+
this.closeConnection(connection).catch(() => {});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async close() {
|
|
607
|
+
const connections = [...this.connections.values()];
|
|
608
|
+
this.connections.clear();
|
|
609
|
+
this.pending.clear();
|
|
610
|
+
await Promise.all(connections.map((connection) => this.closeConnection(connection).catch(() => {})));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function createWebSearchManager(options = {}) {
|
|
615
|
+
return new WebSearchManager(options);
|
|
616
|
+
}
|