sxng-cli 1.0.5 → 1.0.7

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 (65) 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 +88 -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 +155 -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 +153 -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 +69 -0
  55. package/dist/deep/session.d.ts.map +1 -0
  56. package/dist/deep/session.js +206 -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 +394 -100
  64. package/dist/runCli.js.map +1 -1
  65. package/package.json +6 -2
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
@@ -163,10 +213,8 @@ function formatAsMarkdown(data) {
163
213
  lines.push('');
164
214
  for (let i = 0; i < results.length; i++) {
165
215
  const r = results[i];
166
- // Title with score indicator
167
216
  let scoreIndicator = '';
168
217
  if (r.score !== undefined && r.score !== null) {
169
- // Score might be already > 1 or in 0-1 range
170
218
  const normalizedScore = r.score > 1 ? r.score : r.score * 100;
171
219
  scoreIndicator = ` [${normalizedScore.toFixed(1)}]`;
172
220
  }
@@ -176,7 +224,6 @@ function formatAsMarkdown(data) {
176
224
  lines.push(r.content);
177
225
  lines.push('');
178
226
  }
179
- // Metadata: engine, category, publishedDate (if available)
180
227
  const meta = [];
181
228
  meta.push(`Engine: ${r.engine || 'unknown'}`);
182
229
  if (r.category)
@@ -191,13 +238,13 @@ function formatAsMarkdown(data) {
191
238
  lines.push('No results found.');
192
239
  lines.push('');
193
240
  }
194
- // Unresponsive engines (compact format)
241
+ // Unresponsive engines
195
242
  if (data.unresponsiveEngines && data.unresponsiveEngines.length > 0) {
196
243
  const unresponsive = data.unresponsiveEngines.map((item) => Array.isArray(item) ? item[0] : item).join(', ');
197
244
  lines.push(`*Unresponsive: ${unresponsive}*`);
198
245
  lines.push('');
199
246
  }
200
- // Suggestions (if any)
247
+ // Suggestions
201
248
  if (data.suggestions && data.suggestions.length > 0) {
202
249
  lines.push('**Suggestions:** ' + data.suggestions.join(' · '));
203
250
  lines.push('');
@@ -205,17 +252,52 @@ function formatAsMarkdown(data) {
205
252
  return lines.join('\n');
206
253
  }
207
254
  function formatOutput(data, format) {
208
- switch (format) {
209
- case 'csv':
210
- return formatAsCsv(data.results || []);
211
- case 'html':
212
- return formatAsHtml(data.results || []);
213
- case 'md':
214
- return formatAsMarkdown(data);
215
- case 'json':
216
- default:
217
- return JSON.stringify(data, null, 2);
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);
271
+ }
272
+ else {
273
+ graph = new DirectedGraph();
274
+ }
275
+ }
276
+ catch {
277
+ graph = new DirectedGraph();
278
+ }
218
279
  }
280
+ else {
281
+ graph = new DirectedGraph();
282
+ }
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
+ }
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');
219
301
  }
220
302
  export async function runCli(args, service) {
221
303
  const options = parseArgs(args);
@@ -226,6 +308,15 @@ export async function runCli(args, service) {
226
308
  if (options.init) {
227
309
  return await initConfig();
228
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
+ }
229
320
  if (options.health) {
230
321
  const health = await service.healthCheck();
231
322
  const envelope = health.status === 'healthy'
@@ -276,8 +367,32 @@ export async function runCli(args, service) {
276
367
  return 1;
277
368
  }
278
369
  }
279
- if (!options.query) {
280
- 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"' });
281
396
  console.log(JSON.stringify(envelope, null, 2));
282
397
  return 1;
283
398
  }
@@ -289,45 +404,224 @@ export async function runCli(args, service) {
289
404
  return 1;
290
405
  }
291
406
  }
292
- const searchOptions = {
293
- query: options.query,
294
- engines: options.engines,
295
- categories: options.categories,
296
- limit: options.limit ?? config.defaultLimit,
297
- page: options.page,
298
- language: options.language,
299
- timeRange: options.timeRange,
300
- format: options.format || config.defaultFormat
301
- };
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
+ }
302
415
  try {
303
- const results = await service.search(searchOptions);
304
- const envelope = createSuccessEnvelope({
305
- query: results.query,
306
- totalResults: results.numberOfResults,
307
- returnedResults: results.results.length,
308
- results: results.results,
309
- suggestions: results.suggestions,
310
- answers: results.answers,
311
- unresponsiveEngines: results.unresponsiveEngines
312
- });
313
- const outputFormat = options.format || config.defaultFormat;
314
- if (outputFormat === 'md') {
315
- // Markdown format: output clean markdown directly
316
- 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 = {
317
464
  query: results.query,
318
465
  totalResults: results.numberOfResults,
319
- results: results.results,
466
+ results: displayResults,
320
467
  answers: results.answers,
321
468
  suggestions: results.suggestions,
322
- unresponsiveEngines: results.unresponsiveEngines
323
- }, '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'));
324
612
  }
325
613
  else if (outputFormat === 'json') {
326
- console.log(JSON.stringify(envelope, null, 2));
614
+ console.log(JSON.stringify(createSuccessEnvelope({
615
+ ...displayData,
616
+ returnedResults: fusedResults.length,
617
+ }), null, 2));
327
618
  }
328
619
  else {
329
- // csv or html
330
- 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);
331
625
  }
332
626
  return 0;
333
627
  }