scorpion-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Comprehensive Web Search Tools
3
+ * Multiple search engines for accuracy and redundancy
4
+ *
5
+ * Sources: DuckDuckGo, Bing, Google News
6
+ */
7
+
8
+ import * as cheerio from 'cheerio';
9
+ import fetch from 'node-fetch'; // Explicitly use node-fetch
10
+
11
+ const USER_AGENTS = [
12
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
13
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
14
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
15
+ ];
16
+
17
+ function getRandomUserAgent() {
18
+ return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
19
+ }
20
+
21
+ async function fetchWithTimeout(url, options = {}, timeout = 6000) {
22
+ const controller = new AbortController();
23
+ const timeoutMs = timeout;
24
+
25
+ const fetchPromise = fetch(url, {
26
+ ...options,
27
+ signal: controller.signal,
28
+ redirect: 'follow',
29
+ headers: {
30
+ 'User-Agent': getRandomUserAgent(),
31
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
32
+ 'Accept-Language': 'en-US,en;q=0.9',
33
+ ...options.headers,
34
+ },
35
+ });
36
+
37
+ const timeoutPromise = new Promise((_, reject) => {
38
+ setTimeout(() => {
39
+ controller.abort();
40
+ reject(new Error(`Timeout after ${timeoutMs}ms`));
41
+ }, timeoutMs);
42
+ });
43
+
44
+ try {
45
+ // Bulletproof timeout using Promise.race
46
+ const response = await Promise.race([fetchPromise, timeoutPromise]);
47
+ return response;
48
+ } catch (error) {
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * DuckDuckGo HTML Search - Optimized
55
+ */
56
+ async function searchDuckDuckGo(query, maxResults = 5) {
57
+ try {
58
+ const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
59
+ const response = await fetchWithTimeout(searchUrl, {}, 6000); // 6s timeout for search
60
+ const html = await response.text();
61
+ const $ = cheerio.load(html);
62
+
63
+ const results = [];
64
+ $('.result').each((i, elem) => {
65
+ if (results.length >= maxResults) return false;
66
+
67
+ const $link = $(elem).find('.result__a');
68
+ const $snippet = $(elem).find('.result__snippet');
69
+ let url = $link.attr('href');
70
+
71
+ if (url && url.includes('uddg=')) {
72
+ try { url = decodeURIComponent(url.match(/uddg=([^&]+)/)[1]); } catch (e) { }
73
+ }
74
+
75
+ if (url && url.startsWith('http')) {
76
+ results.push({
77
+ title: $link.text().trim(),
78
+ url,
79
+ snippet: $snippet.text().trim(),
80
+ source: 'DuckDuckGo'
81
+ });
82
+ }
83
+ });
84
+ return results;
85
+ } catch (e) {
86
+ return []; // Fail silently to not block others
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Bing Search - Optimized
92
+ */
93
+ async function searchBing(query, maxResults = 5) {
94
+ try {
95
+ const searchUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}`;
96
+ const response = await fetchWithTimeout(searchUrl, {}, 6000);
97
+ const html = await response.text();
98
+ const $ = cheerio.load(html);
99
+
100
+ const results = [];
101
+ $('li.b_algo').each((i, elem) => {
102
+ if (results.length >= maxResults) return false;
103
+ const $link = $(elem).find('h2 a');
104
+ const url = $link.attr('href');
105
+
106
+ if (url && url.startsWith('http')) {
107
+ results.push({
108
+ title: $link.text().trim(),
109
+ url,
110
+ snippet: $(elem).find('p').text().trim().slice(0, 300),
111
+ source: 'Bing'
112
+ });
113
+ }
114
+ });
115
+ return results;
116
+ } catch (e) {
117
+ return [];
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Google News - Optimized
123
+ */
124
+ async function searchGoogleNews(query, maxResults = 5) {
125
+ try {
126
+ const searchUrl = `https://news.google.com/search?q=${encodeURIComponent(query)}&hl=en-US&gl=US&ceid=US:en`;
127
+ const response = await fetchWithTimeout(searchUrl, {}, 6000);
128
+ const html = await response.text();
129
+ const $ = cheerio.load(html);
130
+
131
+ const results = [];
132
+ $('article').each((i, elem) => {
133
+ if (results.length >= maxResults) return false;
134
+ const $link = $(elem).find('a[href^="./article"]');
135
+ let url = $link.attr('href');
136
+
137
+ if (url) {
138
+ url = 'https://news.google.com' + url.slice(1);
139
+ results.push({
140
+ title: $link.text().trim(),
141
+ url,
142
+ snippet: $(elem).find('time').parent().text().trim(),
143
+ source: 'GoogleNews'
144
+ });
145
+ }
146
+ });
147
+ return results;
148
+ } catch (e) {
149
+ return [];
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Multi-engine search - combines results from multiple sources
155
+ */
156
+ async function multiSearch(query, maxResults = 8) {
157
+ const allResults = [];
158
+ const seenUrls = new Set();
159
+ const errors = [];
160
+
161
+ // Run searches in parallel
162
+ const searches = [
163
+ searchDuckDuckGo(query, 5).catch(e => { errors.push('DDG: ' + e.message); return []; }),
164
+ searchBing(query, 5).catch(e => { errors.push('Bing: ' + e.message); return []; }),
165
+ searchGoogleNews(query, 3).catch(e => { errors.push('GNews: ' + e.message); return []; }),
166
+ ];
167
+
168
+ const results = await Promise.all(searches);
169
+
170
+ // Merge and dedupe results
171
+ for (const sourceResults of results) {
172
+ for (const result of sourceResults) {
173
+ // Normalize URL for deduplication
174
+ const normalizedUrl = result.url.replace(/\/$/, '').replace(/^https?:\/\/(www\.)?/, '').slice(0, 100);
175
+
176
+ if (!seenUrls.has(normalizedUrl)) {
177
+ seenUrls.add(normalizedUrl);
178
+ allResults.push(result);
179
+ }
180
+ }
181
+ }
182
+
183
+ // Sort by having snippet (more informative first)
184
+ allResults.sort((a, b) => (b.snippet?.length || 0) - (a.snippet?.length || 0));
185
+
186
+ return {
187
+ results: allResults.slice(0, maxResults),
188
+ sourcesUsed: results.filter(r => r.length > 0).length,
189
+ errors: errors.length > 0 ? errors : undefined
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Fetch and extract content from URL
195
+ */
196
+ async function fetchPage(url) {
197
+ try {
198
+ if (!url.startsWith('http')) url = 'https://' + url;
199
+
200
+ // Strict 8s timeout for fetching pages
201
+ const response = await fetchWithTimeout(url, {}, 8000);
202
+
203
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
204
+
205
+ const contentType = response.headers.get('content-type') || '';
206
+ if (!contentType.includes('text/html') && !contentType.includes('text/plain')) {
207
+ return { url, title: 'Non-HTML', content: `Content: ${contentType}`, contentLength: 0 };
208
+ }
209
+
210
+ const html = await response.text();
211
+ const $ = cheerio.load(html);
212
+
213
+ // Remove junk
214
+ $('script, style, nav, footer, header, aside, iframe, noscript, svg, form, [class*="cookie"], [class*="popup"], [class*="modal"], [class*="banner"], [id*="ad"], [class*="advertisement"], [class*="sidebar"], [class*="menu"]').remove();
215
+
216
+ // Get title
217
+ const title = $('title').text().trim() ||
218
+ $('h1').first().text().trim() ||
219
+ $('meta[property="og:title"]').attr('content') || 'Untitled';
220
+
221
+ // Get meta description
222
+ const metaDesc = $('meta[name="description"]').attr('content') ||
223
+ $('meta[property="og:description"]').attr('content') || '';
224
+
225
+ // Try main content areas
226
+ let content = '';
227
+ const mainSelectors = ['article', 'main', '[role="main"]', '.post-content', '.article-content',
228
+ '.entry-content', '.content', '#content', '.post', '.article', '.story'];
229
+
230
+ for (const sel of mainSelectors) {
231
+ const $main = $(sel);
232
+ if ($main.length && $main.text().trim().length > 300) {
233
+ content = $main.text();
234
+ break;
235
+ }
236
+ }
237
+
238
+ // Fallback to body text if no main content found
239
+ if (!content || content.length < 300) {
240
+ content = $('body').text();
241
+ }
242
+
243
+ // Clean content
244
+ content = content.replace(/\s+/g, ' ').replace(/\n\s*\n/g, '\n\n').trim();
245
+
246
+ // Prepend meta description
247
+ if (metaDesc && content.length > 0) {
248
+ content = metaDesc + '\n\n' + content;
249
+ }
250
+
251
+ return {
252
+ url,
253
+ title: title.slice(0, 200),
254
+ content: content.slice(0, 8000), // Limit content size
255
+ contentLength: content.length
256
+ };
257
+ } catch (error) {
258
+ // Return error object instead of throwing
259
+ return {
260
+ url,
261
+ title: 'Error',
262
+ content: `Failed to load: ${error.message}`,
263
+ contentLength: 0,
264
+ error: true
265
+ };
266
+ }
267
+ }
268
+
269
+ // ============= Tool Exports =============
270
+
271
+ export const webSearch = {
272
+ schema: {
273
+ type: 'function',
274
+ function: {
275
+ name: 'web_search',
276
+ description: 'Search the web using multiple search engines (DuckDuckGo, Bing, Google News) for comprehensive results. Returns deduplicated results from all sources.',
277
+ parameters: {
278
+ type: 'object',
279
+ required: ['query'],
280
+ properties: {
281
+ query: {
282
+ type: 'string',
283
+ description: 'Search query - be specific for best results'
284
+ },
285
+ max_results: {
286
+ type: 'integer',
287
+ description: 'Max results to return (default: 8)'
288
+ }
289
+ }
290
+ }
291
+ }
292
+ },
293
+ execute: async ({ query, max_results = 8 }) => {
294
+ try {
295
+ const { results, sourcesUsed, errors } = await multiSearch(query, max_results);
296
+
297
+ return JSON.stringify({
298
+ success: results.length > 0,
299
+ query,
300
+ resultCount: results.length,
301
+ sourcesUsed,
302
+ errors,
303
+ results
304
+ });
305
+ } catch (error) {
306
+ return JSON.stringify({ success: false, error: error.message, query });
307
+ }
308
+ }
309
+ };
310
+
311
+ export const webFetch = {
312
+ schema: {
313
+ type: 'function',
314
+ function: {
315
+ name: 'web_fetch',
316
+ description: 'Fetch and read content from a URL. Extracts main article content, removing ads and navigation.',
317
+ parameters: {
318
+ type: 'object',
319
+ required: ['url'],
320
+ properties: {
321
+ url: {
322
+ type: 'string',
323
+ description: 'URL to fetch'
324
+ }
325
+ }
326
+ }
327
+ }
328
+ },
329
+ execute: async ({ url }) => {
330
+ try {
331
+ const result = await fetchPage(url);
332
+ return JSON.stringify({ success: true, ...result });
333
+ } catch (error) {
334
+ return JSON.stringify({ success: false, error: error.message, url });
335
+ }
336
+ }
337
+ };
338
+
339
+ export const researchTopic = {
340
+ schema: {
341
+ type: 'function',
342
+ function: {
343
+ name: 'research_topic',
344
+ description: 'Comprehensive research on a topic: searches multiple engines, then fetches and reads content from top sources. Best for in-depth research.',
345
+ parameters: {
346
+ type: 'object',
347
+ required: ['topic'],
348
+ properties: {
349
+ topic: {
350
+ type: 'string',
351
+ description: 'Topic to research'
352
+ },
353
+ depth: {
354
+ type: 'integer',
355
+ description: 'Number of sources to read in detail (default: 3, max: 5)'
356
+ }
357
+ }
358
+ }
359
+ }
360
+ },
361
+ execute: async ({ topic, depth = 2 }) => {
362
+ // HARD TIMEOUT for entire operation: 25 seconds
363
+ // If it takes longer, we return whatever we have
364
+ const start = Date.now();
365
+ const TIMEOUT_MS = 25000;
366
+
367
+ try {
368
+ // 1. Search (Limit to 5 seconds)
369
+ const searchPromise = multiSearch(topic, 5);
370
+ const timeoutPromise = new Promise(resolve => setTimeout(() => resolve({ results: [], sourcesUsed: 0 }), 5000));
371
+
372
+ const { results: searchResults, sourcesUsed } = await Promise.race([searchPromise, timeoutPromise]);
373
+
374
+ if (!searchResults || searchResults.length === 0) {
375
+ return JSON.stringify({ success: false, error: "Search timed out or found no results", topic });
376
+ }
377
+
378
+ const research = {
379
+ topic,
380
+ searchEnginesUsed: sourcesUsed || 1,
381
+ searchResults: searchResults.slice(0, 5).map(r => ({
382
+ title: r.title,
383
+ url: r.url,
384
+ snippet: r.snippet,
385
+ source: r.source
386
+ })),
387
+ detailedContent: []
388
+ };
389
+
390
+ // 2. Fetch Content (Limit concurrency to 2, Max 2 sources total)
391
+ const maxSources = Math.min(depth, 2);
392
+ const topResults = searchResults.slice(0, maxSources);
393
+
394
+ const fetchPromises = topResults.map(async (result) => {
395
+ // Check if we're already out of time
396
+ if (Date.now() - start > TIMEOUT_MS) return null;
397
+
398
+ try {
399
+ // Individual page fetch limit: 6s
400
+ const page = await fetchPage(result.url);
401
+ return {
402
+ title: page.title,
403
+ url: page.url,
404
+ searchEngine: result.source,
405
+ content: page.content.slice(0, 1500) // Reduced content size for faster processing
406
+ };
407
+ } catch (e) {
408
+ return null;
409
+ }
410
+ });
411
+
412
+ // Wait for fetches but enforce global timeout
413
+ const resultsPromise = Promise.all(fetchPromises);
414
+ const globalTimeout = new Promise(resolve => setTimeout(() => resolve([]), TIMEOUT_MS - (Date.now() - start)));
415
+
416
+ const fetchedPages = await Promise.race([resultsPromise, globalTimeout]);
417
+
418
+ research.detailedContent = (fetchedPages || []).filter(p => p !== null);
419
+
420
+ return JSON.stringify({
421
+ success: true,
422
+ ...research,
423
+ summary: `Found ${searchResults.length} results, fetched ${research.detailedContent.length} sources in ${(Date.now() - start) / 1000}s.`
424
+ });
425
+
426
+ } catch (error) {
427
+ return JSON.stringify({ success: false, error: error.message, topic });
428
+ }
429
+ }
430
+ };
431
+
432
+ // Export for other modules
433
+ export { multiSearch as jinaSearch, fetchPage as jinaFetch, searchDuckDuckGo, searchBing, searchGoogleNews };
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Chart Rendering Utility
3
+ * ASCII charts and graphs for terminal
4
+ */
5
+
6
+ import { colors, chalk } from './formatter.js';
7
+
8
+ /**
9
+ * Render a bar chart
10
+ * @param {Array} data - Data array [{label, value}]
11
+ * @param {Object} options - Chart options
12
+ */
13
+ export function barChart(data, options = {}) {
14
+ const {
15
+ maxWidth = 50,
16
+ showValues = true,
17
+ color = 'cyan',
18
+ title = null
19
+ } = options;
20
+
21
+ if (!data || data.length === 0) {
22
+ console.log(colors.dim(' (No data to display)'));
23
+ return;
24
+ }
25
+
26
+ // Find max value for scaling
27
+ const maxValue = Math.max(...data.map(d => d.value));
28
+ const maxLabelLength = Math.max(...data.map(d => d.label.length));
29
+
30
+ if (title) {
31
+ console.log();
32
+ console.log(colors.bold.white(` ${title}`));
33
+ console.log(colors.dim(' ─'.repeat(30)));
34
+ }
35
+
36
+ console.log();
37
+
38
+ for (const item of data) {
39
+ const percentage = item.value / maxValue;
40
+ const barLength = Math.round(percentage * maxWidth);
41
+ const bar = '█'.repeat(barLength);
42
+ const paddedLabel = item.label.padEnd(maxLabelLength);
43
+
44
+ const colorFn = typeof color === 'function' ? color : chalk[color] || chalk.cyan;
45
+ const valueStr = showValues ? chalk.dim(` ${item.value}`) : '';
46
+
47
+ console.log(` ${chalk.dim(paddedLabel)} ${colorFn(bar)}${valueStr}`);
48
+ }
49
+
50
+ console.log();
51
+ }
52
+
53
+ /**
54
+ * Render a sparkline
55
+ * @param {Array} values - Array of numbers
56
+ * @param {Object} options - Options
57
+ */
58
+ export function sparkline(values, options = {}) {
59
+ const { color = 'cyan', showMinMax = true } = options;
60
+
61
+ if (!values || values.length === 0) {
62
+ return colors.dim('(no data)');
63
+ }
64
+
65
+ const sparks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
66
+ const min = Math.min(...values);
67
+ const max = Math.max(...values);
68
+ const range = max - min;
69
+
70
+ if (range === 0) {
71
+ return sparks[4].repeat(values.length);
72
+ }
73
+
74
+ let line = values.map(v => {
75
+ const normalized = (v - min) / range;
76
+ const index = Math.min(Math.floor(normalized * sparks.length), sparks.length - 1);
77
+ return sparks[index];
78
+ }).join('');
79
+
80
+ const colorFn = chalk[color] || chalk.cyan;
81
+ line = colorFn(line);
82
+
83
+ if (showMinMax) {
84
+ line = chalk.dim(`${min} `) + line + chalk.dim(` ${max}`);
85
+ }
86
+
87
+ return line;
88
+ }
89
+
90
+ /**
91
+ * Render a progress bar
92
+ * @param {number} current - Current value
93
+ * @param {number} total - Total value
94
+ * @param {Object} options - Options
95
+ */
96
+ export function progressBar(current, total, options = {}) {
97
+ const {
98
+ width = 40,
99
+ complete = '█',
100
+ incomplete = '░',
101
+ showPercentage = true,
102
+ color = 'green'
103
+ } = options;
104
+
105
+ const percentage = Math.min(Math.max(current / total, 0), 1);
106
+ const filled = Math.round(width * percentage);
107
+ const empty = width - filled;
108
+
109
+ const colorFn = chalk[color] || chalk.green;
110
+ const bar = colorFn(complete.repeat(filled)) + colors.dim(incomplete.repeat(empty));
111
+
112
+ let result = ` ${bar}`;
113
+ if (showPercentage) {
114
+ const pct = Math.round(percentage * 100);
115
+ result += colors.dim(` ${pct}%`);
116
+ }
117
+
118
+ return result;
119
+ }
120
+
121
+ /**
122
+ * Render a distribution chart (histogram)
123
+ * @param {Object} distribution - {label: count}
124
+ * @param {Object} options - Options
125
+ */
126
+ export function distributionChart(distribution, options = {}) {
127
+ const { title = 'Distribution', maxWidth = 40 } = options;
128
+
129
+ const data = Object.entries(distribution).map(([label, value]) => ({ label, value }));
130
+ barChart(data, { ...options, title, maxWidth });
131
+ }
132
+
133
+ /**
134
+ * Render a simple line chart with ASCII art
135
+ * @param {Array} values - Array of numbers
136
+ * @param {Object} options - Options
137
+ */
138
+ export function lineChart(values, options = {}) {
139
+ const { height = 10, width = 60, title = null, color = 'cyan' } = options;
140
+
141
+ if (!values || values.length === 0) {
142
+ console.log(colors.dim(' (No data to display)'));
143
+ return;
144
+ }
145
+
146
+ if (title) {
147
+ console.log();
148
+ console.log(colors.bold.white(` ${title}`));
149
+ console.log(colors.dim(' ─'.repeat(30)));
150
+ }
151
+
152
+ const min = Math.min(...values);
153
+ const max = Math.max(...values);
154
+ const range = max - min;
155
+
156
+ console.log();
157
+
158
+ // Create grid
159
+ for (let row = height; row >= 0; row--) {
160
+ const threshold = min + (range * row / height);
161
+ let line = ' ';
162
+
163
+ for (let i = 0; i < values.length; i++) {
164
+ const value = values[i];
165
+ const prevValue = i > 0 ? values[i - 1] : value;
166
+
167
+ if ((value >= threshold && prevValue < threshold) ||
168
+ (value < threshold && prevValue >= threshold)) {
169
+ line += chalk[color]('╱');
170
+ } else if (value >= threshold) {
171
+ line += chalk[color]('█');
172
+ } else {
173
+ line += ' ';
174
+ }
175
+ }
176
+
177
+ console.log(line);
178
+ }
179
+
180
+ console.log();
181
+ }
182
+
183
+ /**
184
+ * Render a comparison table with bars
185
+ * @param {Array} items - Array of {name, value, target}
186
+ */
187
+ export function comparisonChart(items, options = {}) {
188
+ const { title = 'Comparison' } = options;
189
+
190
+ console.log();
191
+ console.log(colors.bold.white(` ${title}`));
192
+ console.log(colors.dim(' ─'.repeat(50)));
193
+ console.log();
194
+
195
+ for (const item of items) {
196
+ const bar = sparkline([item.value, item.target || item.value], { color: 'cyan', showMinMax: false });
197
+ const percentage = item.target ? Math.round((item.value / item.target) * 100) : 100;
198
+ const status = percentage >= 100 ? colors.success('✓') : colors.dim('○');
199
+
200
+ console.log(` ${status} ${item.name.padEnd(20)} ${bar} ${colors.dim(percentage + '%')}`);
201
+ }
202
+
203
+ console.log();
204
+ }
205
+
206
+ export default {
207
+ barChart,
208
+ sparkline,
209
+ progressBar,
210
+ distributionChart,
211
+ lineChart,
212
+ comparisonChart
213
+ };