sxng-cli 1.0.9 → 1.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.
Files changed (104) hide show
  1. package/dist/commands/graph-add.d.ts.map +1 -1
  2. package/dist/commands/graph-add.js +9 -1
  3. package/dist/commands/graph-add.js.map +1 -1
  4. package/dist/commands/graph-drill.d.ts +11 -0
  5. package/dist/commands/graph-drill.d.ts.map +1 -0
  6. package/dist/commands/graph-drill.js +26 -0
  7. package/dist/commands/graph-drill.js.map +1 -0
  8. package/dist/commands/graph-explore.d.ts +10 -0
  9. package/dist/commands/graph-explore.d.ts.map +1 -0
  10. package/dist/commands/graph-explore.js +32 -0
  11. package/dist/commands/graph-explore.js.map +1 -0
  12. package/dist/commands/graph-obfuscate.d.ts +17 -0
  13. package/dist/commands/graph-obfuscate.d.ts.map +1 -0
  14. package/dist/commands/graph-obfuscate.js +110 -0
  15. package/dist/commands/graph-obfuscate.js.map +1 -0
  16. package/dist/commands/graph-search.d.ts +11 -0
  17. package/dist/commands/graph-search.d.ts.map +1 -0
  18. package/dist/commands/graph-search.js +19 -0
  19. package/dist/commands/graph-search.js.map +1 -0
  20. package/dist/commands/graph-traverse.d.ts +10 -0
  21. package/dist/commands/graph-traverse.d.ts.map +1 -0
  22. package/dist/commands/graph-traverse.js +39 -0
  23. package/dist/commands/graph-traverse.js.map +1 -0
  24. package/dist/commands/query-graph.d.ts +2 -0
  25. package/dist/commands/query-graph.d.ts.map +1 -1
  26. package/dist/commands/query-graph.js +66 -0
  27. package/dist/commands/query-graph.js.map +1 -1
  28. package/dist/deep/co-occurrence.d.ts +50 -0
  29. package/dist/deep/co-occurrence.d.ts.map +1 -0
  30. package/dist/deep/co-occurrence.js +95 -0
  31. package/dist/deep/co-occurrence.js.map +1 -0
  32. package/dist/deep/degree-utils.d.ts +39 -0
  33. package/dist/deep/degree-utils.d.ts.map +1 -0
  34. package/dist/deep/degree-utils.js +42 -0
  35. package/dist/deep/degree-utils.js.map +1 -0
  36. package/dist/deep/extractor.d.ts +7 -2
  37. package/dist/deep/extractor.d.ts.map +1 -1
  38. package/dist/deep/extractor.js +143 -78
  39. package/dist/deep/extractor.js.map +1 -1
  40. package/dist/deep/graph-explore.d.ts +115 -0
  41. package/dist/deep/graph-explore.d.ts.map +1 -0
  42. package/dist/deep/graph-explore.js +586 -0
  43. package/dist/deep/graph-explore.js.map +1 -0
  44. package/dist/deep/graph-obfuscate.d.ts +51 -0
  45. package/dist/deep/graph-obfuscate.d.ts.map +1 -0
  46. package/dist/deep/graph-obfuscate.js +226 -0
  47. package/dist/deep/graph-obfuscate.js.map +1 -0
  48. package/dist/deep/graph-preprocess.d.ts +41 -0
  49. package/dist/deep/graph-preprocess.d.ts.map +1 -0
  50. package/dist/deep/graph-preprocess.js +32 -0
  51. package/dist/deep/graph-preprocess.js.map +1 -0
  52. package/dist/deep/graph-sampling.d.ts +51 -0
  53. package/dist/deep/graph-sampling.d.ts.map +1 -0
  54. package/dist/deep/graph-sampling.js +409 -0
  55. package/dist/deep/graph-sampling.js.map +1 -0
  56. package/dist/deep/graph.d.ts +24 -1
  57. package/dist/deep/graph.d.ts.map +1 -1
  58. package/dist/deep/graph.js +42 -1
  59. package/dist/deep/graph.js.map +1 -1
  60. package/dist/deep/iteration-data.d.ts +38 -0
  61. package/dist/deep/iteration-data.d.ts.map +1 -0
  62. package/dist/deep/iteration-data.js +48 -0
  63. package/dist/deep/iteration-data.js.map +1 -0
  64. package/dist/deep/path-extraction.d.ts +53 -0
  65. package/dist/deep/path-extraction.d.ts.map +1 -0
  66. package/dist/deep/path-extraction.js +229 -0
  67. package/dist/deep/path-extraction.js.map +1 -0
  68. package/dist/deep/quality-assess.d.ts +45 -0
  69. package/dist/deep/quality-assess.d.ts.map +1 -0
  70. package/dist/deep/quality-assess.js +125 -0
  71. package/dist/deep/quality-assess.js.map +1 -0
  72. package/dist/deep/query-redundancy.d.ts +39 -0
  73. package/dist/deep/query-redundancy.d.ts.map +1 -0
  74. package/dist/deep/query-redundancy.js +109 -0
  75. package/dist/deep/query-redundancy.js.map +1 -0
  76. package/dist/deep/query-suggest.d.ts +35 -0
  77. package/dist/deep/query-suggest.d.ts.map +1 -0
  78. package/dist/deep/query-suggest.js +98 -0
  79. package/dist/deep/query-suggest.js.map +1 -0
  80. package/dist/deep/recovery-analysis.d.ts +61 -0
  81. package/dist/deep/recovery-analysis.d.ts.map +1 -0
  82. package/dist/deep/recovery-analysis.js +188 -0
  83. package/dist/deep/recovery-analysis.js.map +1 -0
  84. package/dist/deep/search-strategy.d.ts +37 -0
  85. package/dist/deep/search-strategy.d.ts.map +1 -0
  86. package/dist/deep/search-strategy.js +99 -0
  87. package/dist/deep/search-strategy.js.map +1 -0
  88. package/dist/deep/session.d.ts +2 -0
  89. package/dist/deep/session.d.ts.map +1 -1
  90. package/dist/deep/session.js +1 -1
  91. package/dist/deep/session.js.map +1 -1
  92. package/dist/deep/simhash.d.ts +5 -0
  93. package/dist/deep/simhash.d.ts.map +1 -1
  94. package/dist/deep/simhash.js +16 -0
  95. package/dist/deep/simhash.js.map +1 -1
  96. package/dist/deep/tfidf.d.ts +38 -0
  97. package/dist/deep/tfidf.d.ts.map +1 -0
  98. package/dist/deep/tfidf.js +103 -0
  99. package/dist/deep/tfidf.js.map +1 -0
  100. package/dist/runCli.d.ts +3 -1
  101. package/dist/runCli.d.ts.map +1 -1
  102. package/dist/runCli.js +762 -307
  103. package/dist/runCli.js.map +1 -1
  104. package/package.json +17 -4
package/dist/runCli.js CHANGED
@@ -1,212 +1,37 @@
1
1
  /**
2
- * SearXNG CLI - CLI Runner
2
+ * SearXNG CLI - CLI Runner (Commander-based)
3
3
  */
4
+ import { Command } from 'commander';
4
5
  import { createSuccessEnvelope, createErrorEnvelope } from './protocol.js';
5
6
  import { config } from './config.js';
6
7
  import { initConfig } from './init.js';
7
8
  import { runExtract } from './commands/extract.js';
9
+ import { runQueryGraph } from './commands/query-graph.js';
10
+ import { runGraphAdd } from './commands/graph-add.js';
11
+ import { runGraphObfuscateCommand } from './commands/graph-obfuscate.js';
8
12
  import { ContentExtractor } from './deep/extractor.js';
9
13
  import { rrf } from './deep/rrf.js';
10
14
  import { normalizeUrl } from './deep/dedupe.js';
11
15
  import { deserializeGraph, serializeGraph, graphStats, resultId } from './deep/graph.js';
12
- import { initSessionDir, resolveSessionPath, appendSessionResults, updateSessionGraph, loadSessionResults } from './deep/session.js';
16
+ import { initSessionDir, resolveSessionPath, appendSessionResults, updateSessionGraph, loadSessionResults, loadSessionGraph } from './deep/session.js';
13
17
  import { runSessionList, runSessionDelete } from './commands/session.js';
18
+ import { graphPreprocess } from './deep/graph-preprocess.js';
19
+ import { checkQueryRedundancy } from './deep/query-redundancy.js';
20
+ import { assessResultQuality } from './deep/quality-assess.js';
21
+ import { generateQuerySuggestions } from './deep/query-suggest.js';
22
+ import { determineSearchStage, getStrategyInfo } from './deep/search-strategy.js';
23
+ import { analyzeRecoveryOptions } from './deep/recovery-analysis.js';
24
+ import { getSessionAnalysis } from './deep/iteration-data.js';
25
+ import { runGraphExplore } from './commands/graph-explore.js';
26
+ import { runGraphDrill } from './commands/graph-drill.js';
27
+ import { runGraphTraverse } from './commands/graph-traverse.js';
28
+ import { runGraphSearch } from './commands/graph-search.js';
14
29
  import { DirectedGraph } from 'graphology';
15
30
  import { readFileSync, writeFileSync, existsSync } from 'fs';
16
- const HELP_TEXT = `SearXNG CLI - Web Search Tool
17
-
18
- Usage:
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
32
-
33
- Search Options:
34
- -e, --engines <engines> Comma-separated list of search engines
35
- -c, --categories <cats> Comma-separated list of categories
36
- -l, --limit <n> Maximum number of results (default: ${config.defaultLimit})
37
- -p, --page <n> Page number for pagination
38
- --lang <code> Language code (e.g., en, zh, ja)
39
- --time <range> Time range: day, week, month, year, all
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
57
-
58
- Examples:
59
- sxng "TypeScript tutorial"
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
66
-
67
- Environment Variables:
68
- SEARXNG_BASE_URL SearXNG server URL
69
- SEARXNG_DEFAULT_ENGINE Default search engine
70
- SEARXNG_ALLOWED_ENGINES Comma-separated allowed engines
71
- SEARXNG_DEFAULT_LIMIT Default result limit
72
- SEARXNG_USE_PROXY Use proxy (true/false)
73
- SEARXNG_PROXY_URL Proxy URL
74
- SEARXNG_TIMEOUT Request timeout in ms
75
- `;
76
- function parseArgs(args) {
77
- const options = {
78
- init: false,
79
- extract: false,
80
- queryGraph: false,
81
- graphAdd: false,
82
- sessionList: false,
83
- sessionDelete: false,
84
- enginesList: false,
85
- categoriesList: false,
86
- health: false,
87
- help: false
88
- };
89
- const positional = [];
90
- for (let i = 0; i < args.length; i++) {
91
- const arg = args[i];
92
- switch (arg) {
93
- case '-h':
94
- case '--help':
95
- options.help = true;
96
- break;
97
- case 'init':
98
- options.init = true;
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;
115
- case '-e':
116
- case '--engines':
117
- options.engines = args[++i]?.split(',').map(e => e.trim()).filter(Boolean);
118
- break;
119
- case '-c':
120
- case '--categories':
121
- options.categories = args[++i]?.split(',').map(c => c.trim()).filter(Boolean);
122
- break;
123
- case '-l':
124
- case '--limit':
125
- options.limit = parseInt(args[++i], 10);
126
- break;
127
- case '-p':
128
- case '--page':
129
- options.page = parseInt(args[++i], 10);
130
- break;
131
- case '--lang':
132
- options.language = args[++i];
133
- break;
134
- case '--time':
135
- const timeVal = args[++i];
136
- if (['day', 'week', 'month', 'year', 'all'].includes(timeVal)) {
137
- options.timeRange = timeVal;
138
- }
139
- break;
140
- case '-f':
141
- case '--format':
142
- const fmt = args[++i];
143
- if (fmt === 'json' || fmt === 'md' || fmt === 'markdown') {
144
- options.format = fmt === 'markdown' ? 'md' : fmt;
145
- }
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;
183
- case '--engines-list':
184
- options.enginesList = true;
185
- break;
186
- case '--categories-list':
187
- options.categoriesList = true;
188
- break;
189
- case '--health':
190
- options.health = true;
191
- break;
192
- default:
193
- if (!arg.startsWith('-')) {
194
- positional.push(arg);
195
- }
196
- break;
197
- }
198
- }
199
- if (positional.length > 0) {
200
- options.query = positional.join(' ');
201
- }
202
- return options;
203
- }
204
31
  function formatAsMarkdown(data) {
205
32
  const lines = [];
206
- // Query info
207
33
  lines.push(`## Search: ${data.query || 'Unknown'}`);
208
34
  lines.push('');
209
- // Results
210
35
  const results = data.results || [];
211
36
  if (results.length > 0) {
212
37
  lines.push(`**${results.length}** results`);
@@ -238,13 +63,11 @@ function formatAsMarkdown(data) {
238
63
  lines.push('No results found.');
239
64
  lines.push('');
240
65
  }
241
- // Unresponsive engines
242
66
  if (data.unresponsiveEngines && data.unresponsiveEngines.length > 0) {
243
67
  const unresponsive = data.unresponsiveEngines.map((item) => Array.isArray(item) ? item[0] : item).join(', ');
244
68
  lines.push(`*Unresponsive: ${unresponsive}*`);
245
69
  lines.push('');
246
70
  }
247
- // Suggestions
248
71
  if (data.suggestions && data.suggestions.length > 0) {
249
72
  lines.push('**Suggestions:** ' + data.suggestions.join(' · '));
250
73
  lines.push('');
@@ -256,8 +79,240 @@ function formatOutput(data, format) {
256
79
  return formatAsMarkdown(data);
257
80
  return JSON.stringify(data, null, 2);
258
81
  }
82
+ function formatPreprocessAsMarkdown(data) {
83
+ const lines = [];
84
+ lines.push('## Graph Preprocess Results');
85
+ lines.push('');
86
+ lines.push(`**Strategy:** ${data.tokenizationStrategy}`);
87
+ lines.push(`**Coverage:** ${data.resultsWithContent}/${data.totalResults} results with content`);
88
+ lines.push(`**Rounds:** ${data.roundsCovered}`);
89
+ if (data.coOccurrenceTruncated)
90
+ lines.push('*Co-occurrence matrix was truncated*');
91
+ lines.push('');
92
+ if (data.tfidfTerms?.length) {
93
+ lines.push('### Top TF-IDF Terms');
94
+ lines.push('');
95
+ lines.push('| Term | TF-IDF | Doc Freq |');
96
+ lines.push('|------|--------|----------|');
97
+ for (const t of data.tfidfTerms.slice(0, 20)) {
98
+ lines.push(`| ${t.term} | ${t.tfidf.toFixed(2)} | ${t.docFreq} |`);
99
+ }
100
+ lines.push('');
101
+ }
102
+ if (data.coOccurrences?.length) {
103
+ lines.push('### Co-occurrences');
104
+ lines.push('');
105
+ lines.push('| Term 1 | Term 2 | Count |');
106
+ lines.push('|--------|--------|-------|');
107
+ for (const p of data.coOccurrences.slice(0, 20)) {
108
+ lines.push(`| ${p.term1} | ${p.term2} | ${p.count} |`);
109
+ }
110
+ lines.push('');
111
+ }
112
+ if (data.existingEntities?.length) {
113
+ lines.push('### Existing Entities');
114
+ lines.push('');
115
+ for (const e of data.existingEntities) {
116
+ lines.push(`- ${e.label} (degree: ${e.degree}${e.entityType ? `, type: ${e.entityType}` : ''})`);
117
+ }
118
+ lines.push('');
119
+ }
120
+ return lines.join('\n');
121
+ }
122
+ function formatQualityAsMarkdown(data) {
123
+ const lines = [];
124
+ lines.push('## Result Quality Assessment');
125
+ lines.push('');
126
+ const verdictEmoji = { good: '✓', acceptable: '△', poor: '✗' };
127
+ lines.push(`**Verdict:** ${data.verdict.toUpperCase()} ${verdictEmoji[data.verdict] || ''}`);
128
+ lines.push('');
129
+ const breakdown = data.breakdown;
130
+ if (breakdown) {
131
+ lines.push('| Indicator | Value | Threshold | Pass |');
132
+ lines.push('|-----------|-------|-----------|------|');
133
+ const indicators = ['resultCount', 'contentDepth', 'entityRichness', 'sourceDiversity', 'novelty'];
134
+ const labels = {
135
+ resultCount: 'Result Count',
136
+ contentDepth: 'Content Depth',
137
+ entityRichness: 'Entity Richness',
138
+ sourceDiversity: 'Source Diversity',
139
+ novelty: 'Novelty',
140
+ };
141
+ for (const key of indicators) {
142
+ const ind = breakdown[key];
143
+ if (ind) {
144
+ const valueStr = key === 'novelty' ? ind.value.toFixed(2) : String(Math.round(ind.value * 100) / 100);
145
+ const thStr = key === 'novelty' ? ind.threshold.toFixed(2) : String(ind.threshold);
146
+ lines.push(`| ${labels[key] || key} | ${valueStr} | ${thStr} | ${ind.pass ? '✓' : '✗'} |`);
147
+ }
148
+ }
149
+ lines.push('');
150
+ }
151
+ if (data.failedIndicators?.length > 0) {
152
+ lines.push(`**Failed:** ${data.failedIndicators.join(', ')}`);
153
+ lines.push('');
154
+ }
155
+ return lines.join('\n');
156
+ }
157
+ function formatSuggestAsMarkdown(data) {
158
+ const lines = [];
159
+ lines.push('## Query Suggestion Data');
160
+ lines.push('');
161
+ lines.push(`**Current Stage:** ${data.currentStage}`);
162
+ lines.push('');
163
+ if (data.topEntities?.length) {
164
+ lines.push('### Top Entities');
165
+ lines.push('');
166
+ lines.push('| Entity | Obfuscated Label | Degree | Frequency | Type |');
167
+ lines.push('|--------|------------------|--------|-----------|------|');
168
+ for (const e of data.topEntities.slice(0, 15)) {
169
+ lines.push(`| ${e.label} | ${e.obfuscatedLabel || '-'} | ${e.degree} | ${e.frequency} | ${e.entityType || '-'} |`);
170
+ }
171
+ lines.push('');
172
+ }
173
+ if (data.unexploredDomains?.length) {
174
+ lines.push('### Unexplored Domains');
175
+ lines.push('');
176
+ for (const d of data.unexploredDomains) {
177
+ lines.push(`- ${d}`);
178
+ }
179
+ lines.push('');
180
+ }
181
+ if (data.roundHistory?.length) {
182
+ lines.push('### Round History');
183
+ lines.push('');
184
+ lines.push('| Round | Query | Results |');
185
+ lines.push('|-------|-------|---------|');
186
+ for (const r of data.roundHistory) {
187
+ lines.push(`| ${r.round} | ${r.query} | ${r.resultCount} |`);
188
+ }
189
+ lines.push('');
190
+ }
191
+ if (data.qualityLastRound) {
192
+ lines.push(`**Last Round Quality:** ${data.qualityLastRound.verdict}`);
193
+ if (data.qualityLastRound.failedIndicators?.length) {
194
+ lines.push(` (failed: ${data.qualityLastRound.failedIndicators.join(', ')})`);
195
+ }
196
+ lines.push('');
197
+ }
198
+ return lines.join('\n');
199
+ }
200
+ function formatStrategyAsMarkdown(data) {
201
+ const lines = [];
202
+ lines.push('## Search Strategy Info');
203
+ lines.push('');
204
+ lines.push(`**Current Stage:** ${data.currentStage}`);
205
+ lines.push(`**Round:** ${data.roundNumber}`);
206
+ lines.push(`**Growth Rate:** ${data.growthRate.toFixed(2)}`);
207
+ lines.push('');
208
+ lines.push(`**Recommended Engines:** ${data.recommendedEngines?.join(', ') || '-'}`);
209
+ lines.push(`**Recommended Categories:** ${data.recommendedCategories?.join(', ') || '-'}`);
210
+ if (data.transitionReason) {
211
+ lines.push('');
212
+ lines.push(`**Transition Reason:** ${data.transitionReason}`);
213
+ }
214
+ lines.push('');
215
+ return lines.join('\n');
216
+ }
217
+ function formatRecoveryAsMarkdown(data) {
218
+ const lines = [];
219
+ lines.push('## Recovery Analysis');
220
+ lines.push('');
221
+ const verdictEmoji = { good: '✓', acceptable: '△', poor: '✗' };
222
+ lines.push(`**Quality:** ${data.qualityScore.verdict.toUpperCase()} ${verdictEmoji[data.qualityScore.verdict] || ''}`);
223
+ lines.push(`**Consecutive Failures:** ${data.recentFailures}`);
224
+ lines.push('');
225
+ if (data.lastSuccessfulRound) {
226
+ lines.push(`**Last Successful Round:** Round ${data.lastSuccessfulRound.round} — "${data.lastSuccessfulRound.query}"`);
227
+ lines.push('');
228
+ }
229
+ if (data.availableStrategies.length > 0) {
230
+ lines.push('### Available Strategies');
231
+ lines.push('');
232
+ for (const s of data.availableStrategies) {
233
+ lines.push(`**${s.strategy}**`);
234
+ lines.push(`- Reason: ${s.reason}`);
235
+ lines.push(`- Suggestion: ${s.suggestion}`);
236
+ if (s.backtrackTo) {
237
+ lines.push(`- Backtrack to: Round ${s.backtrackTo.round} — "${s.backtrackTo.query}"`);
238
+ }
239
+ lines.push('');
240
+ }
241
+ }
242
+ if (data.roundQualityHistory.length > 0) {
243
+ lines.push('### Quality History');
244
+ lines.push('');
245
+ lines.push('| Round | Query | Verdict | Failed |');
246
+ lines.push('|-------|-------|---------|--------|');
247
+ for (const r of data.roundQualityHistory) {
248
+ lines.push(`| ${r.round} | ${r.query} | ${r.verdict} | ${r.failedIndicators.join(', ') || '-'} |`);
249
+ }
250
+ lines.push('');
251
+ }
252
+ return lines.join('\n');
253
+ }
254
+ function formatSessionReportAsMarkdown(data) {
255
+ const lines = [];
256
+ lines.push('## Session Report');
257
+ lines.push('');
258
+ // Quality summary
259
+ const q = data.quality;
260
+ const verdictEmoji = { good: '✓', acceptable: '△', poor: '✗' };
261
+ lines.push(`### Quality: ${q.verdict.toUpperCase()} ${verdictEmoji[q.verdict] || ''}`);
262
+ lines.push('');
263
+ if (q.breakdown) {
264
+ const indicators = ['resultCount', 'contentDepth', 'entityRichness', 'sourceDiversity', 'novelty'];
265
+ const labels = {
266
+ resultCount: 'Result Count', contentDepth: 'Content Depth',
267
+ entityRichness: 'Entity Richness', sourceDiversity: 'Source Diversity', novelty: 'Novelty',
268
+ };
269
+ for (const key of indicators) {
270
+ const ind = q.breakdown[key];
271
+ if (ind) {
272
+ const valueStr = key === 'novelty' ? ind.value.toFixed(2) : String(Math.round(ind.value * 100) / 100);
273
+ lines.push(`- ${labels[key]}: ${valueStr} (threshold: ${ind.threshold}) ${ind.pass ? '✓' : '✗'}`);
274
+ }
275
+ }
276
+ lines.push('');
277
+ }
278
+ // Strategy
279
+ const s = data.strategy;
280
+ lines.push(`### Strategy: ${s.currentStage}`);
281
+ lines.push(`- Round: ${s.roundNumber}`);
282
+ lines.push(`- Growth Rate: ${s.growthRate.toFixed(2)}`);
283
+ lines.push(`- Engines: ${s.recommendedEngines.join(', ')}`);
284
+ lines.push(`- Categories: ${s.recommendedCategories.join(', ')}`);
285
+ if (s.transitionReason)
286
+ lines.push(`- Transition: ${s.transitionReason}`);
287
+ lines.push('');
288
+ // Top entities
289
+ if (data.suggestions.topEntities?.length) {
290
+ lines.push('### Top Entities');
291
+ lines.push('');
292
+ for (const e of data.suggestions.topEntities.slice(0, 5)) {
293
+ lines.push(`- ${e.label} (degree: ${e.degree}, freq: ${e.frequency})`);
294
+ }
295
+ lines.push('');
296
+ }
297
+ // Recovery hint
298
+ if (q.verdict !== 'good') {
299
+ lines.push(`> Recovery: ${data.recovery.availableStrategies.length} strategies available. Use \`sxng recovery-analysis <session>\` for details.`);
300
+ lines.push('');
301
+ }
302
+ return lines.join('\n');
303
+ }
304
+ /** Load query strings from session graph history. */
305
+ function loadSessionQueryHistory(sessionDir) {
306
+ const graph = loadSessionGraph(sessionDir);
307
+ const queries = [];
308
+ graph.forEachNode((node, attrs) => {
309
+ if (attrs.type === 'query' && attrs.query) {
310
+ queries.push(attrs.query);
311
+ }
312
+ });
313
+ return queries;
314
+ }
259
315
  function addToGraph(graphFile, results) {
260
- // Load existing graph or create new one
261
316
  let graph;
262
317
  if (existsSync(graphFile)) {
263
318
  try {
@@ -280,8 +335,6 @@ function addToGraph(graphFile, results) {
280
335
  else {
281
336
  graph = new DirectedGraph();
282
337
  }
283
- // Save result metadata nodes (title, url, rank) into the graph
284
- // Entity nodes and relationships are added by Agent via graph-add
285
338
  for (let i = 0; i < results.length; i++) {
286
339
  const r = results[i];
287
340
  const id = resultId(r.url);
@@ -299,99 +352,9 @@ function addToGraph(graphFile, results) {
299
352
  const stats = graphStats(graph);
300
353
  writeFileSync(graphFile, JSON.stringify({ status: 'ok', data: { graph: serialized, stats } }, null, 2), 'utf-8');
301
354
  }
302
- export async function runCli(args, service) {
303
- const options = parseArgs(args);
304
- if (options.help) {
305
- console.log(HELP_TEXT);
306
- return 0;
307
- }
308
- if (options.init) {
309
- return await initConfig();
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
- }
320
- if (options.health) {
321
- const health = await service.healthCheck();
322
- const envelope = health.status === 'healthy'
323
- ? createSuccessEnvelope(health)
324
- : createErrorEnvelope('HEALTH_CHECK_FAILED', health.error || 'SearXNG server is not responding', { hint: `Check if SearXNG is running at ${config.baseUrl}` });
325
- console.log(JSON.stringify(envelope, null, 2));
326
- return health.status === 'healthy' ? 0 : 1;
327
- }
328
- if (options.enginesList) {
329
- try {
330
- const engines = await service.getEngines();
331
- if (engines.length === 0) {
332
- const envelope = createErrorEnvelope('ENGINES_FETCH_EMPTY', 'No engines returned from SearXNG server', { hint: 'Check if SearXNG server is properly configured' });
333
- console.log(JSON.stringify(envelope, null, 2));
334
- return 1;
335
- }
336
- const envelope = createSuccessEnvelope({
337
- engines,
338
- source: 'server'
339
- });
340
- console.log(JSON.stringify(envelope, null, 2));
341
- return 0;
342
- }
343
- catch (error) {
344
- const envelope = createErrorEnvelope('ENGINES_FETCH_FAILED', error instanceof Error ? error.message : 'Failed to fetch engines', { hint: 'Check your network connection and SearXNG server status' });
345
- console.log(JSON.stringify(envelope, null, 2));
346
- return 1;
347
- }
348
- }
349
- if (options.categoriesList) {
350
- try {
351
- const categories = await service.getCategories();
352
- if (categories.length === 0) {
353
- const envelope = createErrorEnvelope('CATEGORIES_FETCH_EMPTY', 'No categories returned from SearXNG server', { hint: 'Check if SearXNG server is properly configured' });
354
- console.log(JSON.stringify(envelope, null, 2));
355
- return 1;
356
- }
357
- const envelope = createSuccessEnvelope({
358
- categories,
359
- source: 'server'
360
- });
361
- console.log(JSON.stringify(envelope, null, 2));
362
- return 0;
363
- }
364
- catch (error) {
365
- const envelope = createErrorEnvelope('CATEGORIES_FETCH_FAILED', error instanceof Error ? error.message : 'Failed to fetch categories', { hint: 'Check your network connection and SearXNG server status' });
366
- console.log(JSON.stringify(envelope, null, 2));
367
- return 1;
368
- }
369
- }
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) {
355
+ async function runSearch(service, options) {
356
+ const queries = options.queries || (options.query ? [options.query] : []);
357
+ if (queries.length === 0) {
395
358
  const envelope = createErrorEnvelope('MISSING_QUERY', 'No search query provided', { hint: 'Use: sxng "your search query" or sxng --queries "q1,q2,q3"' });
396
359
  console.log(JSON.stringify(envelope, null, 2));
397
360
  return 1;
@@ -404,16 +367,67 @@ export async function runCli(args, service) {
404
367
  return 1;
405
368
  }
406
369
  }
407
- const queries = options.queries || (options.query ? [options.query] : []);
408
370
  const limit = options.limit ?? config.defaultLimit;
409
- // Initialize session directory if --session specified
410
371
  if (options.session) {
411
372
  const resolved = resolveSessionPath(options.session);
412
373
  options.session = resolved;
413
374
  initSessionDir(resolved, options.owner, options.desc, options.query);
414
375
  }
376
+ // Pre-search redundancy check
377
+ if (options.redundancy && options.session) {
378
+ const history = loadSessionQueryHistory(options.session);
379
+ const redundancyConfig = {
380
+ jaccardThreshold: 0.7,
381
+ action: options.redundancy,
382
+ };
383
+ const adjustedQueries = [];
384
+ const skippedQueries = [];
385
+ for (const q of queries) {
386
+ const result = checkQueryRedundancy(q, history, redundancyConfig);
387
+ if (result.isRedundant) {
388
+ if (redundancyConfig.action === 'skip') {
389
+ skippedQueries.push(q);
390
+ continue;
391
+ }
392
+ else if (redundancyConfig.action === 'adjust' && result.adjustedQuery) {
393
+ adjustedQueries.push(result.adjustedQuery);
394
+ }
395
+ else {
396
+ // warn — proceed but output warning
397
+ adjustedQueries.push(q);
398
+ }
399
+ // Output redundancy info
400
+ if (redundancyConfig.action === 'warn' || redundancyConfig.action === 'adjust') {
401
+ const envelope = createSuccessEnvelope({
402
+ redundancyWarning: {
403
+ query: q,
404
+ adjustedQuery: result.adjustedQuery,
405
+ maxSimilarity: result.maxSimilarity,
406
+ similarQueries: result.similarQueries,
407
+ action: redundancyConfig.action,
408
+ },
409
+ });
410
+ console.error(JSON.stringify(envelope, null, 2));
411
+ }
412
+ }
413
+ else {
414
+ adjustedQueries.push(q);
415
+ }
416
+ }
417
+ if (skippedQueries.length > 0 && skippedQueries.length === queries.length) {
418
+ // All queries skipped
419
+ const envelope = createSuccessEnvelope({
420
+ message: 'All queries skipped due to redundancy',
421
+ skippedQueries,
422
+ });
423
+ console.log(JSON.stringify(envelope, null, 2));
424
+ return 0;
425
+ }
426
+ // Replace queries with adjusted ones (skipped ones removed)
427
+ queries.length = 0;
428
+ queries.push(...adjustedQueries);
429
+ }
415
430
  try {
416
- // Single query: use SearXNG directly (it already aggregates/sorts)
417
431
  if (queries.length === 1 && !options.merge) {
418
432
  const searchOptions = {
419
433
  query: queries[0],
@@ -425,7 +439,6 @@ export async function runCli(args, service) {
425
439
  timeRange: options.timeRange,
426
440
  };
427
441
  const results = await service.search(searchOptions);
428
- // Session: accumulate results and update graph
429
442
  let sessionInfo = null;
430
443
  if (options.session) {
431
444
  sessionInfo = appendSessionResults(options.session, results.results);
@@ -436,8 +449,7 @@ export async function runCli(args, service) {
436
449
  })));
437
450
  }
438
451
  }
439
- const outputFormat = options.format || config.defaultFormat;
440
- // When session active, RRF-merge with all accumulated results for display
452
+ const outputFormat = (options.format || config.defaultFormat);
441
453
  let displayResults = results.results;
442
454
  if (options.session) {
443
455
  const allSessionResults = loadSessionResults(options.session);
@@ -478,13 +490,11 @@ export async function runCli(args, service) {
478
490
  returnedResults: displayResults.length,
479
491
  }), null, 2));
480
492
  }
481
- // Auto-insert result metadata into knowledge graph if --graph specified
482
493
  if (options.graph) {
483
494
  addToGraph(options.graph, results.results);
484
495
  }
485
496
  return 0;
486
497
  }
487
- // Multi-query or --merge: RRF fusion across ranked lists
488
498
  const allResponses = [];
489
499
  for (const query of queries) {
490
500
  const searchOptions = {
@@ -499,9 +509,7 @@ export async function runCli(args, service) {
499
509
  const response = await service.search(searchOptions);
500
510
  allResponses.push(response);
501
511
  }
502
- // Build rankings for RRF: each query's results as a separate ranked list
503
512
  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
513
  let mergeData = null;
506
514
  if (options.merge) {
507
515
  try {
@@ -522,9 +530,7 @@ export async function runCli(args, service) {
522
530
  return 1;
523
531
  }
524
532
  }
525
- // RRF fusion
526
533
  const rrfFused = rrf(rankings);
527
- // Map RRF scores back to original results
528
534
  const allResults = [];
529
535
  const allSuggestions = [];
530
536
  const allAnswers = [];
@@ -545,7 +551,6 @@ export async function runCli(args, service) {
545
551
  allUnresponsive.push(name);
546
552
  }
547
553
  }
548
- // Include historical results in the URL map if merging
549
554
  if (mergeData) {
550
555
  let mergeResults = mergeData;
551
556
  if (mergeData.status === 'ok' && mergeData.data) {
@@ -566,7 +571,6 @@ export async function runCli(args, service) {
566
571
  }
567
572
  }
568
573
  }
569
- // Dedup by URL to get unique results, then apply RRF order
570
574
  const urlMap = new Map();
571
575
  for (const r of allResults) {
572
576
  const norm = normalizeUrl(r.url);
@@ -584,7 +588,6 @@ export async function runCli(args, service) {
584
588
  if (limit > 0) {
585
589
  fusedResults = fusedResults.slice(0, limit);
586
590
  }
587
- // Session: accumulate results and update graph
588
591
  let sessionInfo = null;
589
592
  if (options.session) {
590
593
  sessionInfo = appendSessionResults(options.session, fusedResults);
@@ -596,7 +599,7 @@ export async function runCli(args, service) {
596
599
  }
597
600
  }
598
601
  const displayQuery = queries.length > 1 ? queries.join(' · ') : queries[0];
599
- const outputFormat = options.format || config.defaultFormat;
602
+ const outputFormat = (options.format || config.defaultFormat);
600
603
  const displayData = {
601
604
  query: displayQuery,
602
605
  queries,
@@ -619,7 +622,6 @@ export async function runCli(args, service) {
619
622
  else {
620
623
  console.log(formatOutput(displayData, outputFormat));
621
624
  }
622
- // Auto-insert result metadata into knowledge graph if --graph specified
623
625
  if (options.graph) {
624
626
  addToGraph(options.graph, fusedResults);
625
627
  }
@@ -634,4 +636,457 @@ export async function runCli(args, service) {
634
636
  return 1;
635
637
  }
636
638
  }
639
+ export function createProgram() {
640
+ const program = new Command();
641
+ program
642
+ .name('sxng')
643
+ .description('SearXNG CLI - Web Search Tool')
644
+ .version('1.0.9')
645
+ .argument('[query]', 'Search query')
646
+ .option('-e, --engines <engines>', 'Comma-separated list of search engines')
647
+ .option('-c, --categories <cats>', 'Comma-separated list of categories')
648
+ .option('-l, --search-limit <n>', 'Maximum number of results', val => parseInt(val, 10))
649
+ .option('-p, --page <n>', 'Page number for pagination', val => parseInt(val, 10))
650
+ .option('--lang <code>', 'Language code (e.g., en, zh, ja)')
651
+ .option('--time <range>', 'Time range: day, week, month, year, all')
652
+ .option('--output-format <fmt>', 'Output format: md (default), json')
653
+ .option('--queries <q1,q2,q3>', 'Multi-query with RRF fusion')
654
+ .option('--merge <file>', 'Merge new results with previous search JSON')
655
+ .option('--search-session <dir|new>', 'Session dir, or "new" to auto-create')
656
+ .option('--owner <name>', 'Session owner (stored in meta.json)')
657
+ .option('--desc <text>', 'Session description (stored in meta.json)')
658
+ .option('--graph <file>', 'Save search result metadata to knowledge graph file')
659
+ .option('--redundancy <action>', 'Query redundancy check: warn | adjust | skip')
660
+ .option('--quality', 'Assess result quality for current session')
661
+ .option('--threshold-override <json>', 'Override quality thresholds (JSON, e.g. \'{"resultCount":10}\')')
662
+ .option('--health', 'Check SearXNG server health')
663
+ .option('--engines-list', 'List available search engines')
664
+ .option('--categories-list', 'List available categories')
665
+ .allowUnknownOption(false);
666
+ program
667
+ .command('init')
668
+ .description('Interactive configuration setup')
669
+ .action(async () => {
670
+ const code = await initConfig();
671
+ process.exit(code);
672
+ });
673
+ program
674
+ .command('extract')
675
+ .description('Extract article content from URLs or session results')
676
+ .option('--urls <url1,url2>', 'URLs to extract content from')
677
+ .option('--from-json <file>', 'Extract from search results JSON file')
678
+ .option('--session <dir>', 'Extract from session results and merge content back')
679
+ .action(async (opts) => {
680
+ const extractor = new ContentExtractor();
681
+ const extractOptions = {
682
+ urls: opts.urls?.split(',').map((u) => u.trim()).filter(Boolean),
683
+ fromJson: opts.fromJson,
684
+ session: opts.session,
685
+ };
686
+ const code = await runExtract(extractor, extractOptions);
687
+ process.exit(code);
688
+ });
689
+ program
690
+ .command('query-graph')
691
+ .argument('<path>', 'Graph file or session name')
692
+ .description('[DEPRECATED] Query subgraph via BFS. Use graph-explore + graph-drill instead.')
693
+ .option('--seeds <s1,s2>', 'Seed nodes for BFS')
694
+ .option('--depth <n>', 'BFS depth (default: 2)', val => parseInt(val, 10), 2)
695
+ .option('--strategy <strategy>', 'Sampling strategy: augmented_chain | dual_core_bridge | community_core_path | deep_chain | mixed')
696
+ .option('-f, --format <fmt>', 'Output format: md, json')
697
+ .action(async (path, opts) => {
698
+ console.warn('[DEPRECATED] query-graph is deprecated. Use graph-explore + graph-drill instead.');
699
+ const graphFormat = opts.format === 'json' ? 'json' : 'md';
700
+ const code = await runQueryGraph({
701
+ graphFile: path,
702
+ seeds: opts.seeds?.split(',').map((s) => s.trim()).filter(Boolean) || [],
703
+ depth: opts.depth ?? 2,
704
+ format: graphFormat,
705
+ strategy: opts.strategy,
706
+ });
707
+ process.exit(code);
708
+ });
709
+ program
710
+ .command('graph-add')
711
+ .argument('<path>', 'Graph file or session name')
712
+ .description('Add entities/edges to knowledge graph')
713
+ .requiredOption('--data <json>', 'JSON with entities/edges')
714
+ .action(async (path, opts) => {
715
+ const code = await runGraphAdd({
716
+ graphFile: path,
717
+ data: opts.data,
718
+ });
719
+ process.exit(code);
720
+ });
721
+ program
722
+ .command('session-list')
723
+ .description('List all sessions')
724
+ .action(async () => {
725
+ const code = await runSessionList();
726
+ process.exit(code);
727
+ });
728
+ program
729
+ .command('session-delete')
730
+ .argument('[names]', 'Comma-separated session names to delete')
731
+ .option('--older <h>', 'Delete sessions older than N hours', val => parseFloat(val))
732
+ .action(async (names, opts) => {
733
+ const nameList = (names || '').split(',').map((n) => n.trim()).filter(Boolean);
734
+ const code = await runSessionDelete(nameList, opts.older);
735
+ process.exit(code);
736
+ });
737
+ program
738
+ .command('graph-obfuscate')
739
+ .argument('<path>', 'Graph file or session name')
740
+ .description('List obfuscation candidates or apply fallback rules (experimental)')
741
+ .option('--list', 'List entities needing obfuscation (default)')
742
+ .option('--fallback-rules', 'Apply simple rule-based obfuscation (experimental)')
743
+ .option('--skip-types <t1,t2>', 'Entity types to skip')
744
+ .option('-f, --format <fmt>', 'Output format: json (default), md')
745
+ .addHelpText('after', `
746
+ Examples:
747
+ sxng graph-obfuscate my-session --list
748
+ sxng graph-obfuscate my-session --fallback-rules --format md
749
+
750
+ Note: --fallback-rules is experimental and may produce low-quality obfuscation labels.
751
+ Recommended workflow: use --list to get candidates, then have an LLM generate obfuscated labels,
752
+ and write them back via graph-add.`)
753
+ .action(async (path, opts) => {
754
+ const code = await runGraphObfuscateCommand({
755
+ graphFile: path,
756
+ list: opts.list ?? !opts.fallbackRules,
757
+ fallbackRules: opts.fallbackRules ?? false,
758
+ format: opts.format === 'md' ? 'md' : 'json',
759
+ skipEntityTypes: opts.skipTypes?.split(',').map((t) => t.trim()).filter(Boolean),
760
+ });
761
+ process.exit(code);
762
+ });
763
+ program
764
+ .command('graph-preprocess')
765
+ .argument('<session>', 'Session directory or name')
766
+ .description('Preprocess session data: TF-IDF, co-occurrence, entity context')
767
+ .option('--top <n>', 'Top N terms to return', val => parseInt(val, 10), 30)
768
+ .option('--co-occurrence-threshold <n>', 'Min co-occurrence count', val => parseInt(val, 10), 2)
769
+ .option('--max-terms <n>', 'Max terms for co-occurrence matrix', val => parseInt(val, 10), 50)
770
+ .option('-f, --format <fmt>', 'Output format: json (default), md')
771
+ .addHelpText('after', `
772
+ Examples:
773
+ sxng graph-preprocess my-session
774
+ sxng graph-preprocess my-session --top 50 --format md`)
775
+ .action(async (session, opts) => {
776
+ const sessionDir = resolveSessionPath(session);
777
+ const result = graphPreprocess(sessionDir, {
778
+ top: opts.top,
779
+ coOccurrenceThreshold: opts.coOccurrenceThreshold,
780
+ maxTermsForCoOccurrence: opts.maxTerms,
781
+ });
782
+ if (opts.format === 'md') {
783
+ console.log(formatPreprocessAsMarkdown(result));
784
+ }
785
+ else {
786
+ console.log(JSON.stringify(createSuccessEnvelope(result), null, 2));
787
+ }
788
+ process.exit(0);
789
+ });
790
+ program
791
+ .command('suggest-queries')
792
+ .argument('<session>', 'Session directory or name')
793
+ .description('Output query suggestion data for Agent follow-up query generation')
794
+ .option('-f, --format <fmt>', 'Output format: json (default), md')
795
+ .addHelpText('after', `
796
+ Examples:
797
+ sxng suggest-queries my-session
798
+ sxng suggest-queries my-session --format md`)
799
+ .action(async (session, opts) => {
800
+ const sessionDir = resolveSessionPath(session);
801
+ const graph = loadSessionGraph(sessionDir);
802
+ const results = loadSessionResults(sessionDir);
803
+ const stage = determineSearchStage(graph);
804
+ const quality = assessResultQuality(results, results, graph);
805
+ const data = generateQuerySuggestions(graph, results, stage, quality);
806
+ if (opts.format === 'md') {
807
+ console.log(formatSuggestAsMarkdown(data));
808
+ }
809
+ else {
810
+ console.log(JSON.stringify(createSuccessEnvelope(data), null, 2));
811
+ }
812
+ process.exit(0);
813
+ });
814
+ program
815
+ .command('strategy-info')
816
+ .argument('<session>', 'Session directory or name')
817
+ .description('Output current search stage recommendation (broad vs targeted)')
818
+ .option('--broad-rounds <n>', 'Rounds before transition check', val => parseInt(val, 10), 2)
819
+ .option('--transition-threshold <n>', 'Growth rate threshold for stage transition', parseFloat, 0.2)
820
+ .option('-f, --format <fmt>', 'Output format: json (default), md')
821
+ .addHelpText('after', `
822
+ Examples:
823
+ sxng strategy-info my-session
824
+ sxng strategy-info my-session --format md`)
825
+ .action(async (session, opts) => {
826
+ const sessionDir = resolveSessionPath(session);
827
+ const graph = loadSessionGraph(sessionDir);
828
+ const info = getStrategyInfo(graph, {
829
+ broadRounds: opts.broadRounds,
830
+ transitionThreshold: opts.transitionThreshold,
831
+ });
832
+ if (opts.format === 'md') {
833
+ console.log(formatStrategyAsMarkdown(info));
834
+ }
835
+ else {
836
+ console.log(JSON.stringify(createSuccessEnvelope(info), null, 2));
837
+ }
838
+ process.exit(0);
839
+ });
840
+ program
841
+ .command('recovery-analysis')
842
+ .argument('<session>', 'Session directory or name')
843
+ .description('Output recovery strategy analysis for Agent decision-making')
844
+ .option('-f, --format <fmt>', 'Output format: json (default), md')
845
+ .addHelpText('after', `
846
+ Examples:
847
+ sxng recovery-analysis my-session
848
+ sxng recovery-analysis my-session --format md`)
849
+ .action(async (session, opts) => {
850
+ const sessionDir = resolveSessionPath(session);
851
+ const graph = loadSessionGraph(sessionDir);
852
+ const results = loadSessionResults(sessionDir);
853
+ const quality = assessResultQuality(results, results, graph);
854
+ const analysis = analyzeRecoveryOptions(graph, results, quality);
855
+ if (opts.format === 'md') {
856
+ console.log(formatRecoveryAsMarkdown(analysis));
857
+ }
858
+ else {
859
+ console.log(JSON.stringify(createSuccessEnvelope(analysis), null, 2));
860
+ }
861
+ process.exit(0);
862
+ });
863
+ program
864
+ .command('session-report')
865
+ .argument('<session>', 'Session directory or name')
866
+ .description('Show session quality history and stage progression')
867
+ .option('-f, --format <fmt>', 'Output format: json (default), md')
868
+ .addHelpText('after', `
869
+ Examples:
870
+ sxng session-report my-session
871
+ sxng session-report my-session --format md`)
872
+ .action(async (session, opts) => {
873
+ const sessionDir = resolveSessionPath(session);
874
+ const graph = loadSessionGraph(sessionDir);
875
+ const results = loadSessionResults(sessionDir);
876
+ const analysis = getSessionAnalysis(graph, results);
877
+ if (opts.format === 'md') {
878
+ console.log(formatSessionReportAsMarkdown(analysis));
879
+ }
880
+ else {
881
+ console.log(JSON.stringify(createSuccessEnvelope(analysis), null, 2));
882
+ }
883
+ process.exit(0);
884
+ });
885
+ program
886
+ .command('graph-explore')
887
+ .argument('<session>', 'Session directory or name')
888
+ .description('List all relations around a seed entity (Agent graph navigation)')
889
+ .requiredOption('--seed <entity>', 'Seed entity label to explore')
890
+ .option('-f, --format <fmt>', 'Output format: md (default), json')
891
+ .addHelpText('after', `
892
+ Examples:
893
+ sxng graph-explore my-session --seed "tokio"
894
+ sxng graph-explore my-session --seed "tokio" --format json
895
+
896
+ See also: graph-drill, graph-search`)
897
+ .action(async (session, opts) => {
898
+ const code = await runGraphExplore({
899
+ session,
900
+ seed: opts.seed,
901
+ format: opts.format === 'json' ? 'json' : 'md',
902
+ });
903
+ process.exit(code);
904
+ });
905
+ program
906
+ .command('graph-drill')
907
+ .argument('<session>', 'Session directory or name')
908
+ .description('Follow specific relations from an entity (Agent graph navigation)')
909
+ .requiredOption('--seed <entity>', 'Seed entity label')
910
+ .requiredOption('--relations <list>', 'Comma-separated relation types to follow')
911
+ .option('-f, --format <fmt>', 'Output format: md (default), json')
912
+ .addHelpText('after', `
913
+ Examples:
914
+ sxng graph-drill my-session --seed "tokio" --relations "alternative_to"
915
+ sxng graph-drill my-session --seed "tokio" --relations "alternative_to,depends_on" --format json
916
+
917
+ See also: graph-explore`)
918
+ .action(async (session, opts) => {
919
+ const code = await runGraphDrill({
920
+ session,
921
+ seed: opts.seed,
922
+ relations: opts.relations.split(',').map((r) => r.trim()).filter(Boolean),
923
+ format: opts.format === 'json' ? 'json' : 'md',
924
+ });
925
+ process.exit(code);
926
+ });
927
+ program
928
+ .command('graph-traverse')
929
+ .argument('<session>', 'Session directory or name')
930
+ .description('Traverse a reasoning path by path node ID (requires path nodes from graph-preprocess)')
931
+ .requiredOption('--path <path-id>', 'Path node ID (e.g. p:chain_001)')
932
+ .option('-f, --format <fmt>', 'Output format: md (default), json')
933
+ .addHelpText('after', `
934
+ Examples:
935
+ sxng graph-traverse my-session --path "p:chain_001"
936
+ sxng graph-traverse my-session --path "p:chain_001" --format json
937
+
938
+ Note: Path nodes are created by graph-preprocess. If no paths exist,
939
+ the command will list available path node IDs.`)
940
+ .action(async (session, opts) => {
941
+ const code = await runGraphTraverse({
942
+ session,
943
+ pathId: opts.path,
944
+ format: opts.format === 'json' ? 'json' : 'md',
945
+ });
946
+ process.exit(code);
947
+ });
948
+ program
949
+ .command('graph-search')
950
+ .argument('<session>', 'Session directory or name')
951
+ .description('Keyword search across entity labels (discover entities before exploring)')
952
+ .requiredOption('--keyword <term>', 'Search keyword')
953
+ .option('--limit <n>', 'Max results', val => parseInt(val, 10), 10)
954
+ .option('-f, --format <fmt>', 'Output format: md (default), json')
955
+ .addHelpText('after', `
956
+ Examples:
957
+ sxng graph-search my-session --keyword "async"
958
+ sxng graph-search my-session --keyword "tokio" --limit 5 --format json
959
+
960
+ See also: graph-explore (for viewing relations of a known entity)`)
961
+ .action(async (session, opts) => {
962
+ const code = await runGraphSearch({
963
+ session,
964
+ keyword: opts.keyword,
965
+ limit: opts.limit,
966
+ format: opts.format === 'json' ? 'json' : 'md',
967
+ });
968
+ process.exit(code);
969
+ });
970
+ return program;
971
+ }
972
+ export async function runCli(args, service) {
973
+ const program = createProgram();
974
+ // Custom action for default command (search)
975
+ // Commander passes the [query] argument as first param, then opts
976
+ program.action(async (query, opts) => {
977
+ const queryString = query || '';
978
+ if (opts.health) {
979
+ const health = await service.healthCheck();
980
+ const envelope = health.status === 'healthy'
981
+ ? createSuccessEnvelope(health)
982
+ : createErrorEnvelope('HEALTH_CHECK_FAILED', health.error || 'SearXNG server is not responding', { hint: `Check if SearXNG is running at ${config.baseUrl}` });
983
+ console.log(JSON.stringify(envelope, null, 2));
984
+ process.exit(health.status === 'healthy' ? 0 : 1);
985
+ return;
986
+ }
987
+ if (opts.quality) {
988
+ if (!opts.searchSession) {
989
+ const envelope = createErrorEnvelope('QUALITY_NO_SESSION', '--quality requires --session', { hint: 'Use: sxng --session <name> --quality' });
990
+ console.log(JSON.stringify(envelope, null, 2));
991
+ process.exit(1);
992
+ return;
993
+ }
994
+ const sessionDir = resolveSessionPath(opts.searchSession);
995
+ const sessionResults = loadSessionResults(sessionDir);
996
+ const sessionGraph = loadSessionGraph(sessionDir);
997
+ let thresholdOverride;
998
+ if (opts.thresholdOverride) {
999
+ try {
1000
+ thresholdOverride = JSON.parse(opts.thresholdOverride);
1001
+ }
1002
+ catch {
1003
+ const envelope = createErrorEnvelope('INVALID_THRESHOLD_OVERRIDE', '--threshold-override must be valid JSON', { hint: 'Example: \'{"resultCount":10}\'' });
1004
+ console.log(JSON.stringify(envelope, null, 2));
1005
+ process.exit(1);
1006
+ return;
1007
+ }
1008
+ }
1009
+ const quality = assessResultQuality(sessionResults, sessionResults, sessionGraph, thresholdOverride);
1010
+ const outputFormat = (opts.outputFormat || config.defaultFormat);
1011
+ if (outputFormat === 'md') {
1012
+ console.log(formatQualityAsMarkdown(quality));
1013
+ }
1014
+ else {
1015
+ console.log(JSON.stringify(createSuccessEnvelope(quality), null, 2));
1016
+ }
1017
+ process.exit(0);
1018
+ return;
1019
+ }
1020
+ if (opts.enginesList) {
1021
+ try {
1022
+ const engines = await service.getEngines();
1023
+ if (engines.length === 0) {
1024
+ const envelope = createErrorEnvelope('ENGINES_FETCH_EMPTY', 'No engines returned from SearXNG server', { hint: 'Check if SearXNG server is properly configured' });
1025
+ console.log(JSON.stringify(envelope, null, 2));
1026
+ process.exit(1);
1027
+ return;
1028
+ }
1029
+ const envelope = createSuccessEnvelope({ engines, source: 'server' });
1030
+ console.log(JSON.stringify(envelope, null, 2));
1031
+ process.exit(0);
1032
+ }
1033
+ catch (error) {
1034
+ const envelope = createErrorEnvelope('ENGINES_FETCH_FAILED', error instanceof Error ? error.message : 'Failed to fetch engines', { hint: 'Check your network connection and SearXNG server status' });
1035
+ console.log(JSON.stringify(envelope, null, 2));
1036
+ process.exit(1);
1037
+ }
1038
+ return;
1039
+ }
1040
+ if (opts.categoriesList) {
1041
+ try {
1042
+ const categories = await service.getCategories();
1043
+ if (categories.length === 0) {
1044
+ const envelope = createErrorEnvelope('CATEGORIES_FETCH_EMPTY', 'No categories returned from SearXNG server', { hint: 'Check if SearXNG server is properly configured' });
1045
+ console.log(JSON.stringify(envelope, null, 2));
1046
+ process.exit(1);
1047
+ return;
1048
+ }
1049
+ const envelope = createSuccessEnvelope({ categories, source: 'server' });
1050
+ console.log(JSON.stringify(envelope, null, 2));
1051
+ process.exit(0);
1052
+ }
1053
+ catch (error) {
1054
+ const envelope = createErrorEnvelope('CATEGORIES_FETCH_FAILED', error instanceof Error ? error.message : 'Failed to fetch categories', { hint: 'Check your network connection and SearXNG server status' });
1055
+ console.log(JSON.stringify(envelope, null, 2));
1056
+ process.exit(1);
1057
+ }
1058
+ return;
1059
+ }
1060
+ const queries = opts.queries?.split(',').map((q) => q.trim()).filter(Boolean);
1061
+ const code = await runSearch(service, {
1062
+ query: queryString,
1063
+ queries,
1064
+ engines: opts.engines?.split(',').map((e) => e.trim()).filter(Boolean),
1065
+ categories: opts.categories?.split(',').map((c) => c.trim()).filter(Boolean),
1066
+ limit: opts.searchLimit,
1067
+ page: opts.page,
1068
+ language: opts.lang,
1069
+ timeRange: opts.time,
1070
+ format: opts.outputFormat,
1071
+ session: opts.searchSession,
1072
+ owner: opts.owner,
1073
+ desc: opts.desc,
1074
+ graph: opts.graph,
1075
+ merge: opts.merge,
1076
+ redundancy: opts.redundancy,
1077
+ });
1078
+ process.exit(code);
1079
+ });
1080
+ try {
1081
+ await program.parseAsync(args, { from: 'user' });
1082
+ }
1083
+ catch (error) {
1084
+ if (error instanceof Error && error.name === 'CommanderError') {
1085
+ console.error(error.message);
1086
+ return 1;
1087
+ }
1088
+ throw error;
1089
+ }
1090
+ return null;
1091
+ }
637
1092
  //# sourceMappingURL=runCli.js.map