@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1269 -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 +471 -0
  11. package/src/cli/commands.js +1155 -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 +137 -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 +461 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +407 -0
  27. package/src/providers/ids.js +24 -0
  28. package/src/providers/index.js +103 -0
  29. package/src/providers/nvidia.js +671 -0
  30. package/src/providers/registry.js +104 -0
  31. package/src/providers/river.js +198 -0
  32. package/src/providers/tokenharbor.js +38 -0
  33. package/src/providers/vendor-extras.js +82 -0
  34. package/src/services/clipboard.js +38 -0
  35. package/src/services/diff.js +153 -0
  36. package/src/services/doctor.js +126 -0
  37. package/src/services/logger.js +190 -0
  38. package/src/services/preferences.js +249 -0
  39. package/src/services/processes.js +345 -0
  40. package/src/services/provider-settings.js +226 -0
  41. package/src/services/sessions.js +279 -0
  42. package/src/services/undo.js +133 -0
  43. package/src/services/ushell.js +146 -0
  44. package/src/services/websearch/manager.js +616 -0
  45. package/src/services/websearch/mcp.js +233 -0
  46. package/src/services/websearch/normalize.js +284 -0
  47. package/src/services/websearch/providers.js +178 -0
  48. package/src/services/websearch/serper.js +263 -0
  49. package/src/services/workspace.js +589 -0
  50. package/src/tools/filesystem.js +304 -0
  51. package/src/tools/git.js +114 -0
  52. package/src/tools/index.js +48 -0
  53. package/src/tools/project.js +160 -0
  54. package/src/tools/registry.js +118 -0
  55. package/src/tools/search.js +210 -0
  56. package/src/tools/shell.js +279 -0
  57. package/src/tools/url.js +542 -0
  58. package/src/tools/web.js +67 -0
  59. package/src/utils/errors.js +160 -0
  60. package/src/utils/network.js +129 -0
  61. package/src/utils/redaction.js +73 -0
  62. package/src/utils/validation.js +370 -0
  63. package/src/utils/version.js +24 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Serper-compatible REST search provider.
3
+ *
4
+ * This is the pre-existing `web_search` backend, moved behind the provider
5
+ * interface so it can take part in the same fallback chain as the MCP servers.
6
+ * It keeps its own 20-entry session cache and never retries in a loop.
7
+ */
8
+
9
+ import { CancelledError, SearchError, ValidationError } from '../../utils/errors.js';
10
+
11
+ export const SERPER_DEFAULT_ENDPOINT = 'https://google.serper.dev/search';
12
+ const MAX_QUERY_CHARS = 400;
13
+ const MAX_CACHE_ENTRIES = 20;
14
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
15
+ const MAX_TITLE = 200;
16
+ const MAX_SNIPPET = 320;
17
+ const MAX_URL = 500;
18
+ const MAX_RESULTS = 10;
19
+
20
+ const clean = (value, max) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
21
+
22
+ function pickArray(data, names) {
23
+ for (const name of names) {
24
+ const value = data?.[name];
25
+ if (Array.isArray(value)) return value;
26
+ }
27
+ return [];
28
+ }
29
+
30
+ function pickObject(data, names) {
31
+ for (const name of names) {
32
+ const value = data?.[name];
33
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ function normalizeResult(item, rank) {
39
+ if (!item || typeof item !== 'object') return null;
40
+ const url = clean(item.link || item.url || item.href, MAX_URL);
41
+ if (!url) return null;
42
+ const title = clean(item.title || item.name || item.heading, MAX_TITLE);
43
+ const snippet = clean(
44
+ item.snippet ||
45
+ item.description ||
46
+ item.summary ||
47
+ (Array.isArray(item.snippet_highlighted_words) ? item.snippet_highlighted_words.join(' ') : ''),
48
+ MAX_SNIPPET,
49
+ );
50
+ const source = clean(item.source || item.displayed_link || item.displayLink, 120) || null;
51
+ const date = clean(item.date || item.publishedDate || item.published_at, 40) || null;
52
+ return {
53
+ rank,
54
+ title: title || url,
55
+ url,
56
+ snippet: snippet || null,
57
+ ...(source ? { source } : {}),
58
+ ...(date ? { date } : {}),
59
+ };
60
+ }
61
+
62
+ function normalizeAnswerBox(data) {
63
+ const box = pickObject(data, ['answerBox', 'answer_box', 'answer']);
64
+ if (!box) return null;
65
+ const text = clean(box.answer || box.snippet || box.description || box.text, MAX_SNIPPET);
66
+ if (!text) return null;
67
+ const title = clean(box.title || box.heading, MAX_TITLE) || null;
68
+ const url = clean(box.link || box.url, MAX_URL) || null;
69
+ return { ...(title ? { title } : {}), text, ...(url ? { url } : {}) };
70
+ }
71
+
72
+ function normalizePeopleAlsoAsk(data) {
73
+ return pickArray(data, ['peopleAlsoAsk', 'people_also_ask', 'related_questions'])
74
+ .map((item) => ({
75
+ question: clean(item?.question || item?.title, MAX_TITLE),
76
+ snippet: clean(item?.snippet || item?.answer || item?.description, MAX_SNIPPET) || null,
77
+ }))
78
+ .filter((item) => item.question)
79
+ .slice(0, 4);
80
+ }
81
+
82
+ function normalizeRelated(data) {
83
+ return pickArray(data, ['relatedSearches', 'related_searches'])
84
+ .map((item) => clean(typeof item === 'string' ? item : item?.query, 160))
85
+ .filter(Boolean)
86
+ .slice(0, 5);
87
+ }
88
+
89
+ function httpError(status, query) {
90
+ const suffix = `(query: ${JSON.stringify(query.slice(0, 80))})`;
91
+ if (status === 401 || status === 403) {
92
+ return new SearchError(
93
+ `Web search rejected the API key (HTTP ${status}). Check webSearch.serperApiKey and that the endpoint belongs to the same provider. ${suffix}`,
94
+ { code: 'SEARCH_AUTH', status },
95
+ );
96
+ }
97
+ if (status === 402 || status === 429) {
98
+ return new SearchError(
99
+ `Web search is out of quota or rate limited (HTTP ${status}). Wait before searching again; do not retry in a loop. ${suffix}`,
100
+ { code: 'SEARCH_RATE_LIMIT', status, retryable: true },
101
+ );
102
+ }
103
+ if (status >= 500) {
104
+ return new SearchError(`Web search provider failed (HTTP ${status}). ${suffix}`, {
105
+ code: 'SEARCH_UPSTREAM',
106
+ status,
107
+ retryable: true,
108
+ });
109
+ }
110
+ return new SearchError(`Web search failed (HTTP ${status}). ${suffix}`, { code: 'SEARCH_FAILED', status });
111
+ }
112
+
113
+ /**
114
+ * Build the Serper provider.
115
+ *
116
+ * @param {object} options
117
+ * @param {object} options.config validated config
118
+ * @param {object} [options.logger]
119
+ * @param {Function} [options.fetch] injectable fetch (tests)
120
+ */
121
+ export function createSerperProvider({ config, logger = null, fetch: fetchImpl = null } = {}) {
122
+ const apiKey = String(config?.webSearchApiKey || '').trim();
123
+ const endpoint = String(config?.webSearchEndpoint || SERPER_DEFAULT_ENDPOINT);
124
+ const authHeader = String(config?.webSearchAuthHeader || 'X-API-KEY');
125
+ const baseResults = Math.max(1, Math.min(Number(config?.webSearchResults) || 5, MAX_RESULTS));
126
+ const timeoutMs = Math.max(1000, Number(config?.webSearchTimeoutMs) || 15000);
127
+ const request = fetchImpl || ((...args) => globalThis.fetch(...args));
128
+ const cache = new Map();
129
+ const host = (() => {
130
+ try {
131
+ return new URL(endpoint).host;
132
+ } catch {
133
+ return endpoint;
134
+ }
135
+ })();
136
+
137
+ async function search({ query, depth = 'standard' } = {}, { signal = null } = {}) {
138
+ const text = clean(query, MAX_QUERY_CHARS);
139
+ if (!text) throw new ValidationError('query must be a non-empty string.');
140
+ const mode = depth === 'deep' ? 'deep' : 'standard';
141
+ const num = mode === 'deep' ? Math.min(baseResults * 2, MAX_RESULTS) : baseResults;
142
+ const key = `${mode}\u0000${text.toLowerCase()}`;
143
+
144
+ const cached = cache.get(key);
145
+ if (cached) {
146
+ logger?.debug('web search cache hit', { provider: 'serper', query: text.slice(0, 80) });
147
+ return { ...cached, cached: true, elapsed_ms: 0 };
148
+ }
149
+
150
+ if (signal?.aborted) throw new CancelledError('Task cancelled by user.');
151
+
152
+ const controller = new AbortController();
153
+ let timedOut = false;
154
+ const timer = setTimeout(() => {
155
+ timedOut = true;
156
+ controller.abort();
157
+ }, timeoutMs);
158
+ const onAbort = () => controller.abort();
159
+ signal?.addEventListener('abort', onAbort, { once: true });
160
+
161
+ const startedAt = Date.now();
162
+ let response;
163
+ let raw;
164
+ try {
165
+ response = await request(endpoint, {
166
+ method: 'POST',
167
+ headers: {
168
+ accept: 'application/json',
169
+ 'content-type': 'application/json',
170
+ [authHeader]: authHeader.toLowerCase() === 'authorization' ? `Bearer ${apiKey}` : apiKey,
171
+ },
172
+ body: JSON.stringify({ q: text, num, gl: 'us', hl: 'en' }),
173
+ signal: controller.signal,
174
+ });
175
+ raw = await response.text();
176
+ } catch (error) {
177
+ if (signal?.aborted) throw new CancelledError('Task cancelled by user.');
178
+ if (timedOut || error?.name === 'AbortError') {
179
+ throw new SearchError(`Web search timed out after ${timeoutMs}ms.`, {
180
+ code: 'SEARCH_TIMEOUT',
181
+ retryable: true,
182
+ });
183
+ }
184
+ const reason = error?.cause?.message || error?.message || String(error);
185
+ throw new SearchError(`Cannot reach the web search endpoint ${host}: ${reason}`, {
186
+ code: 'SEARCH_NETWORK',
187
+ retryable: true,
188
+ });
189
+ } finally {
190
+ clearTimeout(timer);
191
+ signal?.removeEventListener('abort', onAbort);
192
+ }
193
+
194
+ if (!response.ok) throw httpError(response.status, text);
195
+ if (typeof raw !== 'string' || raw.length > MAX_RESPONSE_BYTES) {
196
+ throw new SearchError('Web search returned an oversized or empty response.', { code: 'SEARCH_BAD_RESPONSE' });
197
+ }
198
+
199
+ let data;
200
+ try {
201
+ data = raw ? JSON.parse(raw) : null;
202
+ } catch {
203
+ throw new SearchError('Web search returned a response that is not JSON. Check webSearch.endpoint.', {
204
+ code: 'SEARCH_BAD_RESPONSE',
205
+ });
206
+ }
207
+ if (!data || typeof data !== 'object') {
208
+ throw new SearchError('Web search returned an empty JSON body.', { code: 'SEARCH_BAD_RESPONSE' });
209
+ }
210
+
211
+ const results = pickArray(data, ['organic', 'organic_results', 'results'])
212
+ .map((item, index) => normalizeResult(item, index + 1))
213
+ .filter(Boolean)
214
+ .slice(0, num);
215
+
216
+ const output = {
217
+ query: text,
218
+ depth: mode,
219
+ provider: 'serper',
220
+ endpoint: host,
221
+ result_count: results.length,
222
+ results,
223
+ answer_box: normalizeAnswerBox(data),
224
+ people_also_ask: normalizePeopleAlsoAsk(data),
225
+ related_searches: normalizeRelated(data),
226
+ ...(typeof data.credits === 'number' ? { credits: data.credits } : {}),
227
+ cached: false,
228
+ elapsed_ms: Date.now() - startedAt,
229
+ fetched_at: new Date().toISOString(),
230
+ };
231
+
232
+ cache.set(key, output);
233
+ while (cache.size > MAX_CACHE_ENTRIES) cache.delete(cache.keys().next().value);
234
+ logger?.debug('web search complete', {
235
+ provider: 'serper',
236
+ query: text.slice(0, 80),
237
+ results: results.length,
238
+ ms: output.elapsed_ms,
239
+ });
240
+ return output;
241
+ }
242
+
243
+ return {
244
+ id: 'serper',
245
+ label: 'Serper',
246
+ kind: 'rest',
247
+ transport: 'https',
248
+ requiresKey: true,
249
+ configured: Boolean(apiKey),
250
+ enabled: true,
251
+ capabilities: { search: true, extract: false },
252
+ endpoint: host,
253
+ async search(input, ctx) {
254
+ return search(input, ctx);
255
+ },
256
+ async extract() {
257
+ return null;
258
+ },
259
+ async close() {},
260
+ };
261
+ }
262
+
263
+ export const SERPER_LIMITS = { MAX_QUERY_CHARS, MAX_RESULTS, MAX_CACHE_ENTRIES };