sxng-cli 1.0.4 → 1.0.6

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 (66) hide show
  1. package/README.md +120 -4
  2. package/dist/commands/build-graph.d.ts +8 -0
  3. package/dist/commands/build-graph.d.ts.map +1 -0
  4. package/dist/commands/build-graph.js +70 -0
  5. package/dist/commands/build-graph.js.map +1 -0
  6. package/dist/commands/extract.d.ts +11 -0
  7. package/dist/commands/extract.d.ts.map +1 -0
  8. package/dist/commands/extract.js +84 -0
  9. package/dist/commands/extract.js.map +1 -0
  10. package/dist/commands/graph-add.d.ts +13 -0
  11. package/dist/commands/graph-add.d.ts.map +1 -0
  12. package/dist/commands/graph-add.js +148 -0
  13. package/dist/commands/graph-add.js.map +1 -0
  14. package/dist/commands/query-graph.d.ts +11 -0
  15. package/dist/commands/query-graph.d.ts.map +1 -0
  16. package/dist/commands/query-graph.js +146 -0
  17. package/dist/commands/query-graph.js.map +1 -0
  18. package/dist/commands/raw.d.ts +14 -0
  19. package/dist/commands/raw.d.ts.map +1 -0
  20. package/dist/commands/raw.js +125 -0
  21. package/dist/commands/raw.js.map +1 -0
  22. package/dist/commands/session.d.ts +28 -0
  23. package/dist/commands/session.d.ts.map +1 -0
  24. package/dist/commands/session.js +144 -0
  25. package/dist/commands/session.js.map +1 -0
  26. package/dist/config.d.ts +1 -1
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +1 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/deep/dedupe.d.ts +14 -0
  31. package/dist/deep/dedupe.d.ts.map +1 -0
  32. package/dist/deep/dedupe.js +53 -0
  33. package/dist/deep/dedupe.js.map +1 -0
  34. package/dist/deep/extractor.d.ts +29 -0
  35. package/dist/deep/extractor.d.ts.map +1 -0
  36. package/dist/deep/extractor.js +125 -0
  37. package/dist/deep/extractor.js.map +1 -0
  38. package/dist/deep/graph.d.ts +55 -0
  39. package/dist/deep/graph.d.ts.map +1 -0
  40. package/dist/deep/graph.js +153 -0
  41. package/dist/deep/graph.js.map +1 -0
  42. package/dist/deep/index.d.ts +10 -0
  43. package/dist/deep/index.d.ts.map +1 -0
  44. package/dist/deep/index.js +10 -0
  45. package/dist/deep/index.js.map +1 -0
  46. package/dist/deep/keywords.d.ts +27 -0
  47. package/dist/deep/keywords.d.ts.map +1 -0
  48. package/dist/deep/keywords.js +293 -0
  49. package/dist/deep/keywords.js.map +1 -0
  50. package/dist/deep/rrf.d.ts +13 -0
  51. package/dist/deep/rrf.d.ts.map +1 -0
  52. package/dist/deep/rrf.js +23 -0
  53. package/dist/deep/rrf.js.map +1 -0
  54. package/dist/deep/session.d.ts +65 -0
  55. package/dist/deep/session.d.ts.map +1 -0
  56. package/dist/deep/session.js +197 -0
  57. package/dist/deep/session.js.map +1 -0
  58. package/dist/deep/simhash.d.ts +12 -0
  59. package/dist/deep/simhash.d.ts.map +1 -0
  60. package/dist/deep/simhash.js +56 -0
  61. package/dist/deep/simhash.js.map +1 -0
  62. package/dist/runCli.d.ts.map +1 -1
  63. package/dist/runCli.js +403 -135
  64. package/dist/runCli.js.map +1 -1
  65. package/package.json +6 -2
  66. package/sxng.config.example.json +1 -1
package/dist/runCli.js CHANGED
@@ -4,38 +4,65 @@
4
4
  import { createSuccessEnvelope, createErrorEnvelope } from './protocol.js';
5
5
  import { config } from './config.js';
6
6
  import { initConfig } from './init.js';
7
+ import { runExtract } from './commands/extract.js';
8
+ import { ContentExtractor } from './deep/extractor.js';
9
+ import { rrf } from './deep/rrf.js';
10
+ import { normalizeUrl } from './deep/dedupe.js';
11
+ import { deserializeGraph, serializeGraph, graphStats, resultId } from './deep/graph.js';
12
+ import { initSessionDir, resolveSessionPath, appendSessionResults, updateSessionGraph, loadSessionResults } from './deep/session.js';
13
+ import { runSessionList, runSessionDelete } from './commands/session.js';
14
+ import { DirectedGraph } from 'graphology';
15
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
7
16
  const HELP_TEXT = `SearXNG CLI - Web Search Tool
8
17
 
9
18
  Usage:
10
- sxng <query> [options]
11
- sxng init
12
- sxng --engines <engine1,engine2> <query>
13
- sxng --categories <category> <query>
14
- sxng --health
19
+ sxng <query> [options] Single search
20
+ sxng --queries "q1,q2,q3" Multi-query search (RRF fusion)
21
+ sxng --session new <query> Create new session and search (auto-named)
22
+ sxng --session <dir> <query> Multi-round session with result accumulation
23
+ sxng extract --urls/--from-json Extract article content
24
+ sxng extract --session <dir> Extract content from session and merge back
25
+ sxng query-graph <path> --seeds Query subgraph via BFS
26
+ sxng graph-add <path> --data Add entities/edges to knowledge graph
27
+ sxng session-list List all sessions
28
+ sxng session-delete <names> Delete sessions by name
29
+ sxng session-delete --older <h> Delete sessions older than N hours
30
+ sxng init Interactive configuration setup
31
+ sxng --health Check SearXNG server health
15
32
 
16
- Commands:
17
- init Interactive configuration setup
18
- <query> Perform a web search with the given query
19
-
20
- Options:
33
+ Search Options:
21
34
  -e, --engines <engines> Comma-separated list of search engines
22
35
  -c, --categories <cats> Comma-separated list of categories
23
36
  -l, --limit <n> Maximum number of results (default: ${config.defaultLimit})
24
37
  -p, --page <n> Page number for pagination
25
38
  --lang <code> Language code (e.g., en, zh, ja)
26
39
  --time <range> Time range: day, week, month, year, all
27
- -f, --format <fmt> Output format: md, json, csv, html (default: md)
28
- --engines-list List available search engines
29
- --categories-list List available categories
30
- --health Check SearXNG server health
31
- -h, --help Show this help message
40
+ -f, --format <fmt> Output format: md (default), json
41
+ --queries <q1,q2,q3> Multi-query with RRF fusion
42
+ --merge <file> Merge new results with previous search JSON
43
+ --session <dir|new> Session dir, or "new" to auto-create
44
+ --owner <name> Session owner (stored in meta.json)
45
+ --desc <text> Session description (stored in meta.json)
46
+ --graph <file> Save search result metadata to knowledge graph file
47
+
48
+ Extract Options:
49
+ --urls <url1,url2> URLs to extract content from
50
+ --from-json <file> Extract from search results JSON file
51
+ --session <dir> Extract from session results and merge content back
52
+
53
+ Graph Options:
54
+ --seeds <s1,s2> Seed nodes for query-graph BFS
55
+ --depth <n> BFS depth for query-graph (default: 2)
56
+ --data <json> JSON with entities/edges for graph-add
32
57
 
33
58
  Examples:
34
- sxng init
35
59
  sxng "TypeScript tutorial"
36
- sxng --engines google,github "react hooks"
37
- sxng --categories it,science "machine learning"
38
- sxng --limit 5 --time week "latest news"
60
+ sxng --session new "rust async" --owner "agent-1" --desc "async ecosystem research"
61
+ sxng --session /tmp/s "rust async"
62
+ sxng extract --session /tmp/s
63
+ sxng session-list
64
+ sxng session-delete ds_1745000000_abcd123
65
+ sxng session-delete --older 24
39
66
 
40
67
  Environment Variables:
41
68
  SEARXNG_BASE_URL SearXNG server URL
@@ -49,6 +76,11 @@ Environment Variables:
49
76
  function parseArgs(args) {
50
77
  const options = {
51
78
  init: false,
79
+ extract: false,
80
+ queryGraph: false,
81
+ graphAdd: false,
82
+ sessionList: false,
83
+ sessionDelete: false,
52
84
  enginesList: false,
53
85
  categoriesList: false,
54
86
  health: false,
@@ -65,6 +97,21 @@ function parseArgs(args) {
65
97
  case 'init':
66
98
  options.init = true;
67
99
  break;
100
+ case 'extract':
101
+ options.extract = true;
102
+ break;
103
+ case 'query-graph':
104
+ options.queryGraph = true;
105
+ break;
106
+ case 'graph-add':
107
+ options.graphAdd = true;
108
+ break;
109
+ case 'session-list':
110
+ options.sessionList = true;
111
+ break;
112
+ case 'session-delete':
113
+ options.sessionDelete = true;
114
+ break;
68
115
  case '-e':
69
116
  case '--engines':
70
117
  options.engines = args[++i]?.split(',').map(e => e.trim()).filter(Boolean);
@@ -93,10 +140,46 @@ function parseArgs(args) {
93
140
  case '-f':
94
141
  case '--format':
95
142
  const fmt = args[++i];
96
- if (['json', 'csv', 'html', 'md', 'markdown'].includes(fmt)) {
143
+ if (fmt === 'json' || fmt === 'md' || fmt === 'markdown') {
97
144
  options.format = fmt === 'markdown' ? 'md' : fmt;
98
145
  }
99
146
  break;
147
+ case '--queries':
148
+ options.queries = args[++i]?.split(',').map(q => q.trim()).filter(Boolean);
149
+ break;
150
+ case '--urls':
151
+ options.urls = args[++i]?.split(',').map(u => u.trim()).filter(Boolean);
152
+ break;
153
+ case '--from-json':
154
+ options.fromJson = args[++i];
155
+ break;
156
+ case '--merge':
157
+ options.merge = args[++i];
158
+ break;
159
+ case '--graph':
160
+ options.graph = args[++i];
161
+ break;
162
+ case '--session':
163
+ options.session = args[++i];
164
+ break;
165
+ case '--seeds':
166
+ options.seeds = args[++i]?.split(',').map(s => s.trim()).filter(Boolean);
167
+ break;
168
+ case '--depth':
169
+ options.depth = parseInt(args[++i], 10);
170
+ break;
171
+ case '--data':
172
+ options.data = args[++i];
173
+ break;
174
+ case '--owner':
175
+ options.owner = args[++i];
176
+ break;
177
+ case '--desc':
178
+ options.desc = args[++i];
179
+ break;
180
+ case '--older':
181
+ options.older = parseFloat(args[++i]);
182
+ break;
100
183
  case '--engines-list':
101
184
  options.enginesList = true;
102
185
  break;
@@ -118,39 +201,6 @@ function parseArgs(args) {
118
201
  }
119
202
  return options;
120
203
  }
121
- function formatAsCsv(results) {
122
- if (results.length === 0)
123
- return '';
124
- const headers = ['title', 'url', 'content', 'engine', 'category'];
125
- const lines = [headers.join(',')];
126
- for (const r of results) {
127
- const row = headers.map(h => {
128
- const val = String(r[h] || '').replace(/"/g, '""');
129
- return `"${val}"`;
130
- });
131
- lines.push(row.join(','));
132
- }
133
- return lines.join('\n');
134
- }
135
- function formatAsHtml(results) {
136
- const rows = results.map(r => `
137
- <tr>
138
- <td><a href="${r.url}">${r.title}</a></td>
139
- <td>${r.content}</td>
140
- <td>${r.engine}</td>
141
- </tr>
142
- `).join('');
143
- return `<!DOCTYPE html>
144
- <html>
145
- <head><title>Search Results</title></head>
146
- <body>
147
- <table border="1">
148
- <tr><th>Title</th><th>Content</th><th>Engine</th></tr>
149
- ${rows}
150
- </table>
151
- </body>
152
- </html>`;
153
- }
154
204
  function formatAsMarkdown(data) {
155
205
  const lines = [];
156
206
  // Query info
@@ -160,31 +210,27 @@ function formatAsMarkdown(data) {
160
210
  const results = data.results || [];
161
211
  if (results.length > 0) {
162
212
  lines.push(`**${results.length}** results`);
163
- if (data.totalResults) {
164
- lines.push(`Total: ${data.totalResults}`);
165
- }
166
213
  lines.push('');
167
214
  for (let i = 0; i < results.length; i++) {
168
215
  const r = results[i];
169
- lines.push(`### ${i + 1}. [${r.title || 'No Title'}](${r.url || '#'})`);
216
+ let scoreIndicator = '';
217
+ if (r.score !== undefined && r.score !== null) {
218
+ const normalizedScore = r.score > 1 ? r.score : r.score * 100;
219
+ scoreIndicator = ` [${normalizedScore.toFixed(1)}]`;
220
+ }
221
+ lines.push(`### ${i + 1}. [${r.title || 'No Title'}](${r.url || '#'})${scoreIndicator}`);
170
222
  lines.push('');
171
223
  if (r.content) {
172
224
  lines.push(r.content);
173
225
  lines.push('');
174
226
  }
175
- // Metadata line: engine, category, score, publishedDate
176
227
  const meta = [];
177
228
  meta.push(`Engine: ${r.engine || 'unknown'}`);
178
229
  if (r.category)
179
230
  meta.push(`Category: ${r.category}`);
180
- if (r.score !== undefined && r.score !== null)
181
- meta.push(`Score: ${r.score}`);
182
231
  if (r.publishedDate)
183
- meta.push(`Date: ${r.publishedDate}`);
184
- lines.push(meta.join(' | '));
185
- if (r.thumbnail) {
186
- lines.push(`Thumbnail: ${r.thumbnail}`);
187
- }
232
+ meta.push(`Published: ${r.publishedDate}`);
233
+ lines.push(meta.join(' · '));
188
234
  lines.push('');
189
235
  }
190
236
  }
@@ -194,54 +240,64 @@ function formatAsMarkdown(data) {
194
240
  }
195
241
  // Unresponsive engines
196
242
  if (data.unresponsiveEngines && data.unresponsiveEngines.length > 0) {
197
- lines.push('---');
243
+ const unresponsive = data.unresponsiveEngines.map((item) => Array.isArray(item) ? item[0] : item).join(', ');
244
+ lines.push(`*Unresponsive: ${unresponsive}*`);
198
245
  lines.push('');
199
- lines.push('### Unresponsive Engines');
246
+ }
247
+ // Suggestions
248
+ if (data.suggestions && data.suggestions.length > 0) {
249
+ lines.push('**Suggestions:** ' + data.suggestions.join(' · '));
200
250
  lines.push('');
201
- for (const item of data.unresponsiveEngines) {
202
- if (Array.isArray(item)) {
203
- lines.push(`- ${item[0]}: ${item[1] || 'unknown error'}`);
251
+ }
252
+ return lines.join('\n');
253
+ }
254
+ function formatOutput(data, format) {
255
+ if (format === 'md')
256
+ return formatAsMarkdown(data);
257
+ return JSON.stringify(data, null, 2);
258
+ }
259
+ function addToGraph(graphFile, results) {
260
+ // Load existing graph or create new one
261
+ let graph;
262
+ if (existsSync(graphFile)) {
263
+ try {
264
+ const raw = readFileSync(graphFile, 'utf-8');
265
+ const parsed = JSON.parse(raw);
266
+ const graphData = parsed.status === 'ok' && parsed.data?.graph
267
+ ? parsed.data.graph
268
+ : (parsed.nodes && parsed.edges ? parsed : null);
269
+ if (graphData) {
270
+ graph = deserializeGraph(graphData);
204
271
  }
205
272
  else {
206
- lines.push(`- ${item}`);
273
+ graph = new DirectedGraph();
207
274
  }
208
275
  }
209
- lines.push('');
210
- }
211
- // Answers (if any)
212
- if (data.answers && data.answers.length > 0) {
213
- lines.push('---');
214
- lines.push('');
215
- lines.push('### Answers');
216
- lines.push('');
217
- for (const answer of data.answers) {
218
- lines.push(answer);
219
- lines.push('');
276
+ catch {
277
+ graph = new DirectedGraph();
220
278
  }
221
279
  }
222
- // Suggestions (if any)
223
- if (data.suggestions && data.suggestions.length > 0) {
224
- lines.push('---');
225
- lines.push('');
226
- lines.push('### Suggestions');
227
- lines.push('');
228
- lines.push(data.suggestions.map((s) => `- ${s}`).join('\n'));
229
- lines.push('');
280
+ else {
281
+ graph = new DirectedGraph();
230
282
  }
231
- return lines.join('\n');
232
- }
233
- function formatOutput(data, format) {
234
- switch (format) {
235
- case 'csv':
236
- return formatAsCsv(data.results || []);
237
- case 'html':
238
- return formatAsHtml(data.results || []);
239
- case 'md':
240
- return formatAsMarkdown(data);
241
- case 'json':
242
- default:
243
- return JSON.stringify(data, null, 2);
283
+ // Save result metadata nodes (title, url, rank) into the graph
284
+ // Entity nodes and relationships are added by Agent via graph-add
285
+ for (let i = 0; i < results.length; i++) {
286
+ const r = results[i];
287
+ const id = resultId(r.url);
288
+ if (!graph.hasNode(id)) {
289
+ graph.mergeNode(id, {
290
+ type: 'result',
291
+ label: r.title,
292
+ url: r.url,
293
+ title: r.title,
294
+ rank: i + 1,
295
+ });
296
+ }
244
297
  }
298
+ const serialized = serializeGraph(graph);
299
+ const stats = graphStats(graph);
300
+ writeFileSync(graphFile, JSON.stringify({ status: 'ok', data: { graph: serialized, stats } }, null, 2), 'utf-8');
245
301
  }
246
302
  export async function runCli(args, service) {
247
303
  const options = parseArgs(args);
@@ -252,6 +308,15 @@ export async function runCli(args, service) {
252
308
  if (options.init) {
253
309
  return await initConfig();
254
310
  }
311
+ if (options.extract) {
312
+ const extractor = new ContentExtractor();
313
+ const extractOptions = {
314
+ urls: options.urls,
315
+ fromJson: options.fromJson,
316
+ session: options.session,
317
+ };
318
+ return await runExtract(extractor, extractOptions);
319
+ }
255
320
  if (options.health) {
256
321
  const health = await service.healthCheck();
257
322
  const envelope = health.status === 'healthy'
@@ -302,8 +367,32 @@ export async function runCli(args, service) {
302
367
  return 1;
303
368
  }
304
369
  }
305
- if (!options.query) {
306
- const envelope = createErrorEnvelope('MISSING_QUERY', 'No search query provided', { hint: 'Use: sxng "your search query"' });
370
+ if (options.queryGraph) {
371
+ const { runQueryGraph } = await import('./commands/query-graph.js');
372
+ const graphFormat = options.format === 'json' ? 'json' : 'md';
373
+ return await runQueryGraph({
374
+ graphFile: options.query || options.fromJson || '',
375
+ seeds: options.seeds || [],
376
+ depth: options.depth ?? 2,
377
+ format: graphFormat,
378
+ });
379
+ }
380
+ if (options.graphAdd) {
381
+ const { runGraphAdd } = await import('./commands/graph-add.js');
382
+ return await runGraphAdd({
383
+ graphFile: options.query || options.fromJson || '',
384
+ data: options.data || '',
385
+ });
386
+ }
387
+ if (options.sessionList) {
388
+ return await runSessionList();
389
+ }
390
+ if (options.sessionDelete) {
391
+ const names = (options.query || '').split(',').map(n => n.trim()).filter(Boolean);
392
+ return await runSessionDelete(names, options.older);
393
+ }
394
+ if (!options.query && !options.queries) {
395
+ const envelope = createErrorEnvelope('MISSING_QUERY', 'No search query provided', { hint: 'Use: sxng "your search query" or sxng --queries "q1,q2,q3"' });
307
396
  console.log(JSON.stringify(envelope, null, 2));
308
397
  return 1;
309
398
  }
@@ -315,45 +404,224 @@ export async function runCli(args, service) {
315
404
  return 1;
316
405
  }
317
406
  }
318
- const searchOptions = {
319
- query: options.query,
320
- engines: options.engines,
321
- categories: options.categories,
322
- limit: options.limit ?? config.defaultLimit,
323
- page: options.page,
324
- language: options.language,
325
- timeRange: options.timeRange,
326
- format: options.format || config.defaultFormat
327
- };
407
+ const queries = options.queries || (options.query ? [options.query] : []);
408
+ const limit = options.limit ?? config.defaultLimit;
409
+ // Initialize session directory if --session specified
410
+ if (options.session) {
411
+ const resolved = resolveSessionPath(options.session);
412
+ options.session = resolved;
413
+ initSessionDir(resolved, options.owner, options.desc, options.query);
414
+ }
328
415
  try {
329
- const results = await service.search(searchOptions);
330
- const envelope = createSuccessEnvelope({
331
- query: results.query,
332
- totalResults: results.numberOfResults,
333
- returnedResults: results.results.length,
334
- results: results.results,
335
- suggestions: results.suggestions,
336
- answers: results.answers,
337
- unresponsiveEngines: results.unresponsiveEngines
338
- });
339
- const outputFormat = options.format || config.defaultFormat;
340
- if (outputFormat === 'md') {
341
- // Markdown format: output clean markdown directly
342
- console.log(formatOutput({
416
+ // Single query: use SearXNG directly (it already aggregates/sorts)
417
+ if (queries.length === 1 && !options.merge) {
418
+ const searchOptions = {
419
+ query: queries[0],
420
+ engines: options.engines,
421
+ categories: options.categories,
422
+ limit,
423
+ page: options.page,
424
+ language: options.language,
425
+ timeRange: options.timeRange,
426
+ };
427
+ const results = await service.search(searchOptions);
428
+ // Session: accumulate results and update graph
429
+ let sessionInfo = null;
430
+ if (options.session) {
431
+ sessionInfo = appendSessionResults(options.session, results.results);
432
+ for (const query of queries) {
433
+ updateSessionGraph(options.session, query, results.results.map(r => ({
434
+ url: r.url,
435
+ title: r.title,
436
+ })));
437
+ }
438
+ }
439
+ const outputFormat = options.format || config.defaultFormat;
440
+ // When session active, RRF-merge with all accumulated results for display
441
+ let displayResults = results.results;
442
+ if (options.session) {
443
+ const allSessionResults = loadSessionResults(options.session);
444
+ const rankings = [
445
+ results.results.map(r => ({ id: normalizeUrl(r.url), ...r })),
446
+ allSessionResults.map(r => ({ id: normalizeUrl(r.url), ...r })),
447
+ ];
448
+ const fused = rrf(rankings);
449
+ const urlMap = new Map();
450
+ for (const r of results.results)
451
+ urlMap.set(normalizeUrl(r.url), r);
452
+ for (const r of allSessionResults)
453
+ urlMap.set(normalizeUrl(r.url), r);
454
+ displayResults = fused
455
+ .map(item => {
456
+ const original = urlMap.get(item.id);
457
+ return original ? { ...original, score: item.score } : null;
458
+ })
459
+ .filter(Boolean);
460
+ if (limit > 0)
461
+ displayResults = displayResults.slice(0, limit);
462
+ }
463
+ const displayData = {
343
464
  query: results.query,
344
465
  totalResults: results.numberOfResults,
345
- results: results.results,
466
+ results: displayResults,
346
467
  answers: results.answers,
347
468
  suggestions: results.suggestions,
348
- unresponsiveEngines: results.unresponsiveEngines
349
- }, 'md'));
469
+ unresponsiveEngines: results.unresponsiveEngines,
470
+ ...(sessionInfo ? { session: { dir: options.session, added: sessionInfo.added, total: sessionInfo.total } } : {}),
471
+ };
472
+ if (outputFormat === 'md') {
473
+ console.log(formatOutput(displayData, 'md'));
474
+ }
475
+ else {
476
+ console.log(JSON.stringify(createSuccessEnvelope({
477
+ ...displayData,
478
+ returnedResults: displayResults.length,
479
+ }), null, 2));
480
+ }
481
+ // Auto-insert result metadata into knowledge graph if --graph specified
482
+ if (options.graph) {
483
+ addToGraph(options.graph, results.results);
484
+ }
485
+ return 0;
486
+ }
487
+ // Multi-query or --merge: RRF fusion across ranked lists
488
+ const allResponses = [];
489
+ for (const query of queries) {
490
+ const searchOptions = {
491
+ query,
492
+ engines: options.engines,
493
+ categories: options.categories,
494
+ limit,
495
+ page: options.page,
496
+ language: options.language,
497
+ timeRange: options.timeRange,
498
+ };
499
+ const response = await service.search(searchOptions);
500
+ allResponses.push(response);
501
+ }
502
+ // Build rankings for RRF: each query's results as a separate ranked list
503
+ let rankings = allResponses.map(resp => resp.results.map((r) => ({ id: normalizeUrl(r.url), ...r })));
504
+ // If --merge, load historical results and add as another ranking
505
+ let mergeData = null;
506
+ if (options.merge) {
507
+ try {
508
+ const raw = readFileSync(options.merge, 'utf-8');
509
+ mergeData = JSON.parse(raw);
510
+ let mergeResults = mergeData;
511
+ if (mergeData.status === 'ok' && mergeData.data) {
512
+ mergeResults = mergeData.data;
513
+ }
514
+ const historicalResults = (mergeResults.results || []);
515
+ if (historicalResults.length > 0) {
516
+ rankings.push(historicalResults.map((r) => ({ id: normalizeUrl(r.url), ...r })));
517
+ }
518
+ }
519
+ catch (error) {
520
+ const envelope = createErrorEnvelope('MERGE_FILE_FAILED', `Failed to read merge file: ${options.merge}`, { hint: 'Ensure the file exists and contains valid search results JSON' });
521
+ console.log(JSON.stringify(envelope, null, 2));
522
+ return 1;
523
+ }
524
+ }
525
+ // RRF fusion
526
+ const rrfFused = rrf(rankings);
527
+ // Map RRF scores back to original results
528
+ const allResults = [];
529
+ const allSuggestions = [];
530
+ const allAnswers = [];
531
+ const allUnresponsive = [];
532
+ for (const resp of allResponses) {
533
+ allResults.push(...resp.results);
534
+ for (const s of resp.suggestions) {
535
+ if (!allSuggestions.includes(s))
536
+ allSuggestions.push(s);
537
+ }
538
+ for (const a of resp.answers) {
539
+ if (!allAnswers.includes(a))
540
+ allAnswers.push(a);
541
+ }
542
+ for (const u of resp.unresponsiveEngines) {
543
+ const name = Array.isArray(u) ? u[0] : u;
544
+ if (!allUnresponsive.includes(name))
545
+ allUnresponsive.push(name);
546
+ }
547
+ }
548
+ // Include historical results in the URL map if merging
549
+ if (mergeData) {
550
+ let mergeResults = mergeData;
551
+ if (mergeData.status === 'ok' && mergeData.data) {
552
+ mergeResults = mergeData.data;
553
+ }
554
+ const historical = mergeResults.results || [];
555
+ allResults.push(...historical);
556
+ if (mergeResults.suggestions) {
557
+ for (const s of mergeResults.suggestions) {
558
+ if (!allSuggestions.includes(s))
559
+ allSuggestions.push(s);
560
+ }
561
+ }
562
+ if (mergeResults.answers) {
563
+ for (const a of mergeResults.answers) {
564
+ if (!allAnswers.includes(a))
565
+ allAnswers.push(a);
566
+ }
567
+ }
568
+ }
569
+ // Dedup by URL to get unique results, then apply RRF order
570
+ const urlMap = new Map();
571
+ for (const r of allResults) {
572
+ const norm = normalizeUrl(r.url);
573
+ if (!urlMap.has(norm))
574
+ urlMap.set(norm, r);
575
+ }
576
+ let fusedResults = rrfFused
577
+ .map((item) => {
578
+ const original = urlMap.get(item.id);
579
+ if (!original)
580
+ return null;
581
+ return { ...original, score: item.score };
582
+ })
583
+ .filter(Boolean);
584
+ if (limit > 0) {
585
+ fusedResults = fusedResults.slice(0, limit);
586
+ }
587
+ // Session: accumulate results and update graph
588
+ let sessionInfo = null;
589
+ if (options.session) {
590
+ sessionInfo = appendSessionResults(options.session, fusedResults);
591
+ for (const query of queries) {
592
+ updateSessionGraph(options.session, query, fusedResults.map(r => ({
593
+ url: r.url,
594
+ title: r.title,
595
+ })));
596
+ }
597
+ }
598
+ const displayQuery = queries.length > 1 ? queries.join(' · ') : queries[0];
599
+ const outputFormat = options.format || config.defaultFormat;
600
+ const displayData = {
601
+ query: displayQuery,
602
+ queries,
603
+ totalResults: allResults.length,
604
+ results: fusedResults,
605
+ answers: allAnswers,
606
+ suggestions: allSuggestions,
607
+ unresponsiveEngines: allUnresponsive,
608
+ ...(sessionInfo ? { session: { dir: options.session, added: sessionInfo.added, total: sessionInfo.total } } : {}),
609
+ };
610
+ if (outputFormat === 'md') {
611
+ console.log(formatOutput(displayData, 'md'));
350
612
  }
351
613
  else if (outputFormat === 'json') {
352
- console.log(JSON.stringify(envelope, null, 2));
614
+ console.log(JSON.stringify(createSuccessEnvelope({
615
+ ...displayData,
616
+ returnedResults: fusedResults.length,
617
+ }), null, 2));
353
618
  }
354
619
  else {
355
- // csv or html
356
- console.log(formatOutput(results, outputFormat));
620
+ console.log(formatOutput(displayData, outputFormat));
621
+ }
622
+ // Auto-insert result metadata into knowledge graph if --graph specified
623
+ if (options.graph) {
624
+ addToGraph(options.graph, fusedResults);
357
625
  }
358
626
  return 0;
359
627
  }