mcp-vector-search 0.12.6__py3-none-any.whl → 1.1.22__py3-none-any.whl

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 (92) hide show
  1. mcp_vector_search/__init__.py +3 -3
  2. mcp_vector_search/analysis/__init__.py +111 -0
  3. mcp_vector_search/analysis/baseline/__init__.py +68 -0
  4. mcp_vector_search/analysis/baseline/comparator.py +462 -0
  5. mcp_vector_search/analysis/baseline/manager.py +621 -0
  6. mcp_vector_search/analysis/collectors/__init__.py +74 -0
  7. mcp_vector_search/analysis/collectors/base.py +164 -0
  8. mcp_vector_search/analysis/collectors/cohesion.py +463 -0
  9. mcp_vector_search/analysis/collectors/complexity.py +743 -0
  10. mcp_vector_search/analysis/collectors/coupling.py +1162 -0
  11. mcp_vector_search/analysis/collectors/halstead.py +514 -0
  12. mcp_vector_search/analysis/collectors/smells.py +325 -0
  13. mcp_vector_search/analysis/debt.py +516 -0
  14. mcp_vector_search/analysis/interpretation.py +685 -0
  15. mcp_vector_search/analysis/metrics.py +414 -0
  16. mcp_vector_search/analysis/reporters/__init__.py +7 -0
  17. mcp_vector_search/analysis/reporters/console.py +646 -0
  18. mcp_vector_search/analysis/reporters/markdown.py +480 -0
  19. mcp_vector_search/analysis/reporters/sarif.py +377 -0
  20. mcp_vector_search/analysis/storage/__init__.py +93 -0
  21. mcp_vector_search/analysis/storage/metrics_store.py +762 -0
  22. mcp_vector_search/analysis/storage/schema.py +245 -0
  23. mcp_vector_search/analysis/storage/trend_tracker.py +560 -0
  24. mcp_vector_search/analysis/trends.py +308 -0
  25. mcp_vector_search/analysis/visualizer/__init__.py +90 -0
  26. mcp_vector_search/analysis/visualizer/d3_data.py +534 -0
  27. mcp_vector_search/analysis/visualizer/exporter.py +484 -0
  28. mcp_vector_search/analysis/visualizer/html_report.py +2895 -0
  29. mcp_vector_search/analysis/visualizer/schemas.py +525 -0
  30. mcp_vector_search/cli/commands/analyze.py +1062 -0
  31. mcp_vector_search/cli/commands/chat.py +1455 -0
  32. mcp_vector_search/cli/commands/index.py +621 -5
  33. mcp_vector_search/cli/commands/index_background.py +467 -0
  34. mcp_vector_search/cli/commands/init.py +13 -0
  35. mcp_vector_search/cli/commands/install.py +597 -335
  36. mcp_vector_search/cli/commands/install_old.py +8 -4
  37. mcp_vector_search/cli/commands/mcp.py +78 -6
  38. mcp_vector_search/cli/commands/reset.py +68 -26
  39. mcp_vector_search/cli/commands/search.py +224 -8
  40. mcp_vector_search/cli/commands/setup.py +1184 -0
  41. mcp_vector_search/cli/commands/status.py +339 -5
  42. mcp_vector_search/cli/commands/uninstall.py +276 -357
  43. mcp_vector_search/cli/commands/visualize/__init__.py +39 -0
  44. mcp_vector_search/cli/commands/visualize/cli.py +292 -0
  45. mcp_vector_search/cli/commands/visualize/exporters/__init__.py +12 -0
  46. mcp_vector_search/cli/commands/visualize/exporters/html_exporter.py +33 -0
  47. mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +33 -0
  48. mcp_vector_search/cli/commands/visualize/graph_builder.py +647 -0
  49. mcp_vector_search/cli/commands/visualize/layout_engine.py +469 -0
  50. mcp_vector_search/cli/commands/visualize/server.py +600 -0
  51. mcp_vector_search/cli/commands/visualize/state_manager.py +428 -0
  52. mcp_vector_search/cli/commands/visualize/templates/__init__.py +16 -0
  53. mcp_vector_search/cli/commands/visualize/templates/base.py +234 -0
  54. mcp_vector_search/cli/commands/visualize/templates/scripts.py +4542 -0
  55. mcp_vector_search/cli/commands/visualize/templates/styles.py +2522 -0
  56. mcp_vector_search/cli/didyoumean.py +27 -2
  57. mcp_vector_search/cli/main.py +127 -160
  58. mcp_vector_search/cli/output.py +158 -13
  59. mcp_vector_search/config/__init__.py +4 -0
  60. mcp_vector_search/config/default_thresholds.yaml +52 -0
  61. mcp_vector_search/config/settings.py +12 -0
  62. mcp_vector_search/config/thresholds.py +273 -0
  63. mcp_vector_search/core/__init__.py +16 -0
  64. mcp_vector_search/core/auto_indexer.py +3 -3
  65. mcp_vector_search/core/boilerplate.py +186 -0
  66. mcp_vector_search/core/config_utils.py +394 -0
  67. mcp_vector_search/core/database.py +406 -94
  68. mcp_vector_search/core/embeddings.py +24 -0
  69. mcp_vector_search/core/exceptions.py +11 -0
  70. mcp_vector_search/core/git.py +380 -0
  71. mcp_vector_search/core/git_hooks.py +4 -4
  72. mcp_vector_search/core/indexer.py +632 -54
  73. mcp_vector_search/core/llm_client.py +756 -0
  74. mcp_vector_search/core/models.py +91 -1
  75. mcp_vector_search/core/project.py +17 -0
  76. mcp_vector_search/core/relationships.py +473 -0
  77. mcp_vector_search/core/scheduler.py +11 -11
  78. mcp_vector_search/core/search.py +179 -29
  79. mcp_vector_search/mcp/server.py +819 -9
  80. mcp_vector_search/parsers/python.py +285 -5
  81. mcp_vector_search/utils/__init__.py +2 -0
  82. mcp_vector_search/utils/gitignore.py +0 -3
  83. mcp_vector_search/utils/gitignore_updater.py +212 -0
  84. mcp_vector_search/utils/monorepo.py +66 -4
  85. mcp_vector_search/utils/timing.py +10 -6
  86. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/METADATA +184 -53
  87. mcp_vector_search-1.1.22.dist-info/RECORD +120 -0
  88. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/WHEEL +1 -1
  89. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/entry_points.txt +1 -0
  90. mcp_vector_search/cli/commands/visualize.py +0 -1467
  91. mcp_vector_search-0.12.6.dist-info/RECORD +0 -68
  92. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1455 @@
1
+ """Chat command for LLM-powered intelligent code search."""
2
+
3
+ import asyncio
4
+ import os
5
+ from fnmatch import fnmatch
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+ from loguru import logger
11
+ from rich.live import Live
12
+ from rich.markdown import Markdown
13
+ from rich.panel import Panel
14
+
15
+ from ...core.database import ChromaVectorDatabase
16
+ from ...core.embeddings import create_embedding_function
17
+ from ...core.exceptions import ProjectNotFoundError, SearchError
18
+ from ...core.llm_client import LLMClient
19
+ from ...core.project import ProjectManager
20
+ from ...core.search import SemanticSearchEngine
21
+ from ..didyoumean import create_enhanced_typer
22
+ from ..output import (
23
+ console,
24
+ print_error,
25
+ print_info,
26
+ print_success,
27
+ print_warning,
28
+ )
29
+
30
+
31
+ def show_api_key_help() -> None:
32
+ """Display helpful error message when API key is missing."""
33
+ message = """[bold yellow]āš ļø No LLM API Key Found[/bold yellow]
34
+
35
+ The chat feature requires an API key for an LLM provider.
36
+
37
+ [bold cyan]Set one of these environment variables:[/bold cyan]
38
+ • [green]OPENAI_API_KEY[/green] - For OpenAI (GPT-4, etc.) [dim](recommended)[/dim]
39
+ • [green]OPENROUTER_API_KEY[/green] - For OpenRouter (Claude, GPT, etc.)
40
+
41
+ [bold cyan]Example:[/bold cyan]
42
+ [yellow]export OPENAI_API_KEY="sk-..."[/yellow]
43
+ [yellow]export OPENROUTER_API_KEY="sk-or-..."[/yellow]
44
+
45
+ [bold cyan]Get API keys at:[/bold cyan]
46
+ • OpenAI: [link=https://platform.openai.com/api-keys]https://platform.openai.com/api-keys[/link]
47
+ • OpenRouter: [link=https://openrouter.ai/keys]https://openrouter.ai/keys[/link]
48
+
49
+ [dim]Alternatively, run: [cyan]mcp-vector-search setup[/cyan] for interactive setup[/dim]"""
50
+
51
+ panel = Panel(
52
+ message,
53
+ border_style="yellow",
54
+ padding=(1, 2),
55
+ )
56
+ console.print(panel)
57
+
58
+
59
+ class ChatSession:
60
+ """Manages conversation history with automatic compaction.
61
+
62
+ Keeps system prompt intact, compacts older messages when history grows large,
63
+ and maintains recent exchanges for context.
64
+ """
65
+
66
+ # Threshold for compaction (estimated tokens, ~4 chars per token)
67
+ COMPACTION_THRESHOLD = 8000 * 4 # ~32000 chars
68
+ RECENT_EXCHANGES_TO_KEEP = 3 # Keep last N user/assistant pairs
69
+
70
+ def __init__(self, system_prompt: str) -> None:
71
+ """Initialize session with system prompt.
72
+
73
+ Args:
74
+ system_prompt: Initial system message
75
+ """
76
+ self.system_prompt = system_prompt
77
+ self.messages: list[dict[str, str]] = [
78
+ {"role": "system", "content": system_prompt}
79
+ ]
80
+
81
+ def add_message(self, role: str, content: str) -> None:
82
+ """Add message to history and compact if needed.
83
+
84
+ Args:
85
+ role: Message role (user/assistant)
86
+ content: Message content
87
+ """
88
+ self.messages.append({"role": role, "content": content})
89
+
90
+ # Check if compaction needed
91
+ total_chars = sum(len(msg["content"]) for msg in self.messages)
92
+ if total_chars > self.COMPACTION_THRESHOLD:
93
+ self._compact_history()
94
+
95
+ def _compact_history(self) -> None:
96
+ """Compact conversation history by summarizing older exchanges.
97
+
98
+ Strategy:
99
+ 1. Keep system prompt intact
100
+ 2. Summarize older exchanges into brief context
101
+ 3. Keep recent N exchanges verbatim
102
+ """
103
+ logger.debug("Compacting conversation history")
104
+
105
+ # Separate system prompt and conversation
106
+ system_msg = self.messages[0]
107
+ conversation = self.messages[1:]
108
+
109
+ # Keep recent exchanges (last N user/assistant pairs)
110
+ recent_start = max(0, len(conversation) - (self.RECENT_EXCHANGES_TO_KEEP * 2))
111
+ older_messages = conversation[:recent_start]
112
+ recent_messages = conversation[recent_start:]
113
+
114
+ # Summarize older messages
115
+ if older_messages:
116
+ summary_parts = []
117
+ for msg in older_messages:
118
+ role = msg["role"].capitalize()
119
+ content_preview = msg["content"][:100].replace("\n", " ")
120
+ summary_parts.append(f"{role}: {content_preview}...")
121
+
122
+ summary = "\n".join(summary_parts)
123
+ summary_msg = {
124
+ "role": "system",
125
+ "content": f"[Previous conversation summary]\n{summary}\n[End summary]",
126
+ }
127
+
128
+ # Rebuild messages: system + summary + recent
129
+ self.messages = [system_msg, summary_msg] + recent_messages
130
+
131
+ logger.debug(
132
+ f"Compacted {len(older_messages)} old messages, kept {len(recent_messages)} recent"
133
+ )
134
+
135
+ def get_messages(self) -> list[dict[str, str]]:
136
+ """Get current message history.
137
+
138
+ Returns:
139
+ List of message dictionaries
140
+ """
141
+ return self.messages.copy()
142
+
143
+ def clear(self) -> None:
144
+ """Clear conversation history, keeping only system prompt."""
145
+ self.messages = [{"role": "system", "content": self.system_prompt}]
146
+
147
+
148
+ # Create chat subcommand app with "did you mean" functionality
149
+ chat_app = create_enhanced_typer(
150
+ help="šŸ¤– LLM-powered intelligent code search",
151
+ invoke_without_command=True,
152
+ )
153
+
154
+
155
+ @chat_app.callback(invoke_without_command=True)
156
+ def chat_main(
157
+ ctx: typer.Context,
158
+ query: str | None = typer.Argument(
159
+ None,
160
+ help="Natural language query about your code",
161
+ ),
162
+ project_root: Path | None = typer.Option(
163
+ None,
164
+ "--project-root",
165
+ "-p",
166
+ help="Project root directory (auto-detected if not specified)",
167
+ exists=True,
168
+ file_okay=False,
169
+ dir_okay=True,
170
+ readable=True,
171
+ rich_help_panel="šŸ”§ Global Options",
172
+ ),
173
+ limit: int = typer.Option(
174
+ 5,
175
+ "--limit",
176
+ "-l",
177
+ help="Maximum number of results to return",
178
+ min=1,
179
+ max=20,
180
+ rich_help_panel="šŸ“Š Result Options",
181
+ ),
182
+ model: str | None = typer.Option(
183
+ None,
184
+ "--model",
185
+ "-m",
186
+ help="Model to use (defaults based on provider: gpt-4o-mini for OpenAI, claude-3-haiku for OpenRouter)",
187
+ rich_help_panel="šŸ¤– LLM Options",
188
+ ),
189
+ provider: str | None = typer.Option(
190
+ None,
191
+ "--provider",
192
+ help="LLM provider to use: 'openai' or 'openrouter' (auto-detect if not specified)",
193
+ rich_help_panel="šŸ¤– LLM Options",
194
+ ),
195
+ timeout: float | None = typer.Option(
196
+ 30.0,
197
+ "--timeout",
198
+ help="API timeout in seconds",
199
+ min=5.0,
200
+ max=120.0,
201
+ rich_help_panel="šŸ¤– LLM Options",
202
+ ),
203
+ json_output: bool = typer.Option(
204
+ False,
205
+ "--json",
206
+ help="Output results in JSON format",
207
+ rich_help_panel="šŸ“Š Result Options",
208
+ ),
209
+ files: str | None = typer.Option(
210
+ None,
211
+ "--files",
212
+ "-f",
213
+ help="Filter by file glob patterns (e.g., '*.py', 'src/*.js'). Matches basename or relative path.",
214
+ rich_help_panel="šŸ” Filters",
215
+ ),
216
+ think: bool = typer.Option(
217
+ False,
218
+ "--think",
219
+ "-t",
220
+ help="Use advanced model for complex queries (gpt-4o / claude-sonnet-4). Better reasoning, higher cost.",
221
+ rich_help_panel="šŸ¤– LLM Options",
222
+ ),
223
+ ) -> None:
224
+ """šŸ¤– Ask questions about your code in natural language.
225
+
226
+ Uses LLM (OpenAI or OpenRouter) to intelligently search your codebase and answer
227
+ questions like "where is X defined?", "how does Y work?", etc.
228
+
229
+ [bold cyan]Setup:[/bold cyan]
230
+
231
+ [green]Option A - OpenAI (recommended):[/green]
232
+ $ export OPENAI_API_KEY="your-key-here"
233
+ Get a key at: [cyan]https://platform.openai.com/api-keys[/cyan]
234
+
235
+ [green]Option B - OpenRouter:[/green]
236
+ $ export OPENROUTER_API_KEY="your-key-here"
237
+ Get a key at: [cyan]https://openrouter.ai/keys[/cyan]
238
+
239
+ [dim]Provider is auto-detected. OpenAI is preferred if both keys are set.[/dim]
240
+
241
+ [bold cyan]Examples:[/bold cyan]
242
+
243
+ [green]Ask where a parameter is set:[/green]
244
+ $ mcp-vector-search chat "where is similarity_threshold set?"
245
+
246
+ [green]Ask how something works:[/green]
247
+ $ mcp-vector-search chat "how does the indexing process work?"
248
+
249
+ [green]Find implementation details:[/green]
250
+ $ mcp-vector-search chat "show me the search ranking algorithm"
251
+
252
+ [green]Force specific provider:[/green]
253
+ $ mcp-vector-search chat "question" --provider openai
254
+ $ mcp-vector-search chat "question" --provider openrouter
255
+
256
+ [green]Use custom model:[/green]
257
+ $ mcp-vector-search chat "question" --model gpt-4o
258
+ $ mcp-vector-search chat "question" --model anthropic/claude-3.5-sonnet
259
+
260
+ [bold cyan]Advanced:[/bold cyan]
261
+
262
+ [green]Filter by file pattern:[/green]
263
+ $ mcp-vector-search chat "how does validation work?" --files "*.py"
264
+ $ mcp-vector-search chat "find React components" --files "src/*.tsx"
265
+
266
+ [green]Limit results:[/green]
267
+ $ mcp-vector-search chat "find auth code" --limit 3
268
+
269
+ [green]Custom timeout:[/green]
270
+ $ mcp-vector-search chat "complex question" --timeout 60
271
+
272
+ [green]Use advanced model for complex queries:[/green]
273
+ $ mcp-vector-search chat "explain the authentication flow" --think
274
+
275
+ [dim]šŸ’” Tip: Use --think for complex architectural questions. It uses gpt-4o or
276
+ claude-sonnet-4 for better reasoning at higher cost.[/dim]
277
+ """
278
+ # If no query provided and no subcommand invoked, exit (show help)
279
+ if query is None:
280
+ if ctx.invoked_subcommand is None:
281
+ # No query and no subcommand - show help
282
+ raise typer.Exit()
283
+ else:
284
+ # A subcommand was invoked - let it handle the request
285
+ return
286
+
287
+ try:
288
+ project_root = project_root or ctx.obj.get("project_root") or Path.cwd()
289
+
290
+ # Validate provider if specified
291
+ if provider and provider not in ("openai", "openrouter"):
292
+ print_error(
293
+ f"Invalid provider: {provider}. Must be 'openai' or 'openrouter'"
294
+ )
295
+ raise typer.Exit(1)
296
+
297
+ # Run the chat with intent detection and routing
298
+ asyncio.run(
299
+ run_chat_with_intent(
300
+ project_root=project_root,
301
+ query=query,
302
+ limit=limit,
303
+ model=model,
304
+ provider=provider,
305
+ timeout=timeout,
306
+ json_output=json_output,
307
+ files=files,
308
+ think=think,
309
+ )
310
+ )
311
+
312
+ except (typer.Exit, SystemExit):
313
+ # Re-raise exit exceptions without printing additional error messages
314
+ # The error message has already been shown to the user
315
+ raise
316
+ except Exception as e:
317
+ # Log real exceptions (not typer.Exit)
318
+ if not isinstance(e, typer.Exit | SystemExit):
319
+ logger.error(f"Chat failed: {e}")
320
+ print_error(f"Chat failed: {e}")
321
+ raise typer.Exit(
322
+ 1
323
+ ) from None # Suppress exception chain to avoid double-printing
324
+
325
+
326
+ async def run_chat_with_intent(
327
+ project_root: Path,
328
+ query: str,
329
+ limit: int = 5,
330
+ model: str | None = None,
331
+ provider: str | None = None,
332
+ timeout: float = 30.0,
333
+ json_output: bool = False,
334
+ files: str | None = None,
335
+ think: bool = False,
336
+ ) -> None:
337
+ """Route to appropriate chat mode based on detected intent.
338
+
339
+ Args:
340
+ project_root: Project root directory
341
+ query: User's natural language query
342
+ limit: Maximum results to return
343
+ model: Model to use (optional)
344
+ provider: LLM provider
345
+ timeout: API timeout
346
+ json_output: Whether to output JSON
347
+ files: File pattern filter
348
+ think: Use advanced model
349
+ """
350
+ # Initialize LLM client for intent detection
351
+ from ...core.config_utils import (
352
+ get_openai_api_key,
353
+ get_openrouter_api_key,
354
+ get_preferred_llm_provider,
355
+ )
356
+
357
+ config_dir = project_root / ".mcp-vector-search"
358
+ openai_key = get_openai_api_key(config_dir)
359
+ openrouter_key = get_openrouter_api_key(config_dir)
360
+
361
+ # Determine provider (same logic as before)
362
+ if not provider:
363
+ preferred_provider = get_preferred_llm_provider(config_dir)
364
+ if preferred_provider == "openai" and openai_key:
365
+ provider = "openai"
366
+ elif preferred_provider == "openrouter" and openrouter_key:
367
+ provider = "openrouter"
368
+ elif openai_key:
369
+ provider = "openai"
370
+ elif openrouter_key:
371
+ provider = "openrouter"
372
+ else:
373
+ console.print() # Blank line for spacing
374
+ show_api_key_help()
375
+ raise typer.Exit(1)
376
+
377
+ # Create temporary client for intent detection (use fast model)
378
+ try:
379
+ intent_client = LLMClient(
380
+ openai_api_key=openai_key,
381
+ openrouter_api_key=openrouter_key,
382
+ provider=provider,
383
+ timeout=timeout,
384
+ think=False, # Use fast model for intent detection
385
+ )
386
+
387
+ # Detect intent
388
+ intent = await intent_client.detect_intent(query)
389
+
390
+ # Show intent to user
391
+ if intent == "find":
392
+ console.print("\n[cyan]šŸ” Intent: Find[/cyan] - Searching codebase\n")
393
+ await run_chat_search(
394
+ project_root=project_root,
395
+ query=query,
396
+ limit=limit,
397
+ model=model,
398
+ provider=provider,
399
+ timeout=timeout,
400
+ json_output=json_output,
401
+ files=files,
402
+ think=think,
403
+ )
404
+ elif intent == "analyze":
405
+ # Analysis mode - analyze code quality and metrics
406
+ console.print(
407
+ "\n[cyan]šŸ“Š Intent: Analyze[/cyan] - Analyzing code quality\n"
408
+ )
409
+ await run_chat_analyze(
410
+ project_root=project_root,
411
+ query=query,
412
+ model=model,
413
+ provider=provider,
414
+ timeout=timeout,
415
+ think=think,
416
+ )
417
+ else:
418
+ # Answer mode - force think mode and enter interactive session
419
+ console.print(
420
+ "\n[cyan]šŸ’¬ Intent: Answer[/cyan] - Entering interactive mode\n"
421
+ )
422
+ await run_chat_answer(
423
+ project_root=project_root,
424
+ initial_query=query,
425
+ limit=limit,
426
+ model=model,
427
+ provider=provider,
428
+ timeout=timeout,
429
+ files=files,
430
+ )
431
+
432
+ except Exception as e:
433
+ logger.error(f"Intent detection failed: {e}")
434
+ # Default to find mode on error
435
+ console.print("\n[yellow]⚠ Using default search mode[/yellow]\n")
436
+ await run_chat_search(
437
+ project_root=project_root,
438
+ query=query,
439
+ limit=limit,
440
+ model=model,
441
+ provider=provider,
442
+ timeout=timeout,
443
+ json_output=json_output,
444
+ files=files,
445
+ think=think,
446
+ )
447
+
448
+
449
+ async def run_chat_answer(
450
+ project_root: Path,
451
+ initial_query: str,
452
+ limit: int = 5,
453
+ model: str | None = None,
454
+ provider: str | None = None,
455
+ timeout: float = 30.0,
456
+ files: str | None = None,
457
+ ) -> None:
458
+ """Run interactive answer mode with streaming responses.
459
+
460
+ Args:
461
+ project_root: Project root directory
462
+ initial_query: Initial user question
463
+ limit: Max search results for context
464
+ model: Model to use (optional, defaults to advanced model)
465
+ provider: LLM provider
466
+ timeout: API timeout
467
+ files: File pattern filter
468
+ """
469
+ from ...core.config_utils import get_openai_api_key, get_openrouter_api_key
470
+
471
+ config_dir = project_root / ".mcp-vector-search"
472
+ openai_key = get_openai_api_key(config_dir)
473
+ openrouter_key = get_openrouter_api_key(config_dir)
474
+
475
+ # Load project configuration
476
+ project_manager = ProjectManager(project_root)
477
+ if not project_manager.is_initialized():
478
+ raise ProjectNotFoundError(
479
+ f"Project not initialized at {project_root}. Run 'mcp-vector-search init' first."
480
+ )
481
+
482
+ config = project_manager.load_config()
483
+
484
+ # Initialize LLM client with advanced model (force think mode)
485
+ try:
486
+ llm_client = LLMClient(
487
+ openai_api_key=openai_key,
488
+ openrouter_api_key=openrouter_key,
489
+ model=model,
490
+ provider=provider,
491
+ timeout=timeout,
492
+ think=True, # Always use advanced model for answer mode
493
+ )
494
+ provider_display = llm_client.provider.capitalize()
495
+ model_info = f"{llm_client.model} [bold magenta](thinking mode)[/bold magenta]"
496
+ print_success(f"Connected to {provider_display}: {model_info}")
497
+ except ValueError as e:
498
+ print_error(str(e))
499
+ raise typer.Exit(1)
500
+
501
+ # Initialize search engine
502
+ embedding_function, _ = create_embedding_function(config.embedding_model)
503
+ database = ChromaVectorDatabase(
504
+ persist_directory=config.index_path,
505
+ embedding_function=embedding_function,
506
+ )
507
+ search_engine = SemanticSearchEngine(
508
+ database=database,
509
+ project_root=project_root,
510
+ similarity_threshold=config.similarity_threshold,
511
+ )
512
+
513
+ # Initialize session (cleared on startup)
514
+ system_prompt = """You are a helpful code assistant analyzing a codebase. Answer questions based on provided code context.
515
+
516
+ Guidelines:
517
+ - Be concise but thorough
518
+ - Reference specific functions, classes, or files
519
+ - Use code examples when helpful
520
+ - If context is insufficient, say so
521
+ - Use markdown formatting"""
522
+
523
+ session = ChatSession(system_prompt)
524
+
525
+ # Process initial query
526
+ await _process_answer_query(
527
+ query=initial_query,
528
+ llm_client=llm_client,
529
+ search_engine=search_engine,
530
+ database=database,
531
+ session=session,
532
+ project_root=project_root,
533
+ limit=limit,
534
+ files=files,
535
+ config=config,
536
+ )
537
+
538
+ # Interactive loop
539
+ console.print("\n[dim]Type your questions or '/exit' to quit[/dim]\n")
540
+
541
+ while True:
542
+ try:
543
+ # Get user input
544
+ user_input = console.input("\n[bold cyan]You:[/bold cyan] ").strip()
545
+
546
+ if not user_input:
547
+ continue
548
+
549
+ # Check for exit command
550
+ if user_input.lower() in ("/exit", "/quit", "exit", "quit"):
551
+ console.print("\n[cyan]šŸ‘‹ Session ended.[/cyan]")
552
+ break
553
+
554
+ # Process query
555
+ await _process_answer_query(
556
+ query=user_input,
557
+ llm_client=llm_client,
558
+ search_engine=search_engine,
559
+ database=database,
560
+ session=session,
561
+ project_root=project_root,
562
+ limit=limit,
563
+ files=files,
564
+ config=config,
565
+ )
566
+
567
+ except KeyboardInterrupt:
568
+ console.print("\n\n[cyan]šŸ‘‹ Session ended.[/cyan]")
569
+ break
570
+ except EOFError:
571
+ console.print("\n\n[cyan]šŸ‘‹ Session ended.[/cyan]")
572
+ break
573
+ except Exception as e:
574
+ logger.error(f"Error processing query: {e}")
575
+ print_error(f"Error: {e}")
576
+
577
+
578
+ async def run_chat_analyze(
579
+ project_root: Path,
580
+ query: str,
581
+ model: str | None = None,
582
+ provider: str | None = None,
583
+ timeout: float = 30.0,
584
+ think: bool = False,
585
+ ) -> None:
586
+ """Run analysis mode with streaming interpretation.
587
+
588
+ This function:
589
+ 1. Parses the user's analysis question
590
+ 2. Determines which metrics/tools to invoke
591
+ 3. Calls appropriate analysis tools
592
+ 4. Passes results to LLM with specialized analysis prompt
593
+ 5. Returns interpreted insights with streaming output
594
+
595
+ Args:
596
+ project_root: Project root directory
597
+ query: User's analysis question
598
+ model: Model to use (optional)
599
+ provider: LLM provider
600
+ timeout: API timeout
601
+ think: Use advanced model for complex analysis
602
+ """
603
+ import json
604
+
605
+ from ...analysis import ProjectMetrics
606
+ from ...analysis.interpretation import AnalysisInterpreter, EnhancedJSONExporter
607
+ from ...core.config_utils import get_openai_api_key, get_openrouter_api_key
608
+ from ...parsers.registry import ParserRegistry
609
+
610
+ config_dir = project_root / ".mcp-vector-search"
611
+ openai_key = get_openai_api_key(config_dir)
612
+ openrouter_key = get_openrouter_api_key(config_dir)
613
+
614
+ # Load project configuration
615
+ project_manager = ProjectManager(project_root)
616
+ if not project_manager.is_initialized():
617
+ raise ProjectNotFoundError(
618
+ f"Project not initialized at {project_root}. Run 'mcp-vector-search init' first."
619
+ )
620
+
621
+ config = project_manager.load_config()
622
+
623
+ # Initialize LLM client (use advanced model for analysis)
624
+ try:
625
+ llm_client = LLMClient(
626
+ openai_api_key=openai_key,
627
+ openrouter_api_key=openrouter_key,
628
+ model=model,
629
+ provider=provider,
630
+ timeout=timeout,
631
+ think=True, # Always use advanced model for analysis
632
+ )
633
+ provider_display = llm_client.provider.capitalize()
634
+ model_info = f"{llm_client.model} [bold magenta](analysis mode)[/bold magenta]"
635
+ print_success(f"Connected to {provider_display}: {model_info}")
636
+ except ValueError as e:
637
+ print_error(str(e))
638
+ raise typer.Exit(1)
639
+
640
+ # Determine query type and run appropriate analysis
641
+ console.print(f"\n[cyan]šŸ” Analyzing:[/cyan] [white]{query}[/white]\n")
642
+
643
+ # Initialize parser registry and collect metrics
644
+ console.print("[cyan]šŸ“Š Collecting metrics...[/cyan]")
645
+ parser_registry = ParserRegistry()
646
+ project_metrics = ProjectMetrics(root_path=project_root)
647
+
648
+ # Parse all files
649
+ for file_ext in config.file_extensions:
650
+ parser = parser_registry.get_parser(file_ext)
651
+ if parser:
652
+ # Find all files with this extension
653
+ for file_path in project_root.rglob(f"*{file_ext}"):
654
+ # Skip ignored directories
655
+ should_skip = False
656
+ for ignore_pattern in config.ignore_patterns:
657
+ if ignore_pattern in str(file_path):
658
+ should_skip = True
659
+ break
660
+
661
+ if should_skip:
662
+ continue
663
+
664
+ try:
665
+ chunks = parser.parse_file(file_path)
666
+ project_metrics.add_file(file_path, chunks)
667
+ except Exception as e:
668
+ logger.warning(f"Failed to parse {file_path}: {e}")
669
+
670
+ # Generate enhanced export with LLM context
671
+ console.print("[cyan]🧮 Computing analysis context...[/cyan]")
672
+ exporter = EnhancedJSONExporter(project_root=project_root)
673
+ enhanced_export = exporter.export_with_context(
674
+ project_metrics,
675
+ include_smells=True,
676
+ )
677
+
678
+ # Create analysis prompt based on query type
679
+ analysis_context = json.dumps(enhanced_export.model_dump(), indent=2)
680
+
681
+ # Analysis system prompt with grading rubric and code smell interpretation
682
+ analysis_system_prompt = """You are a code quality expert analyzing a codebase. You have access to comprehensive metrics and code smell analysis.
683
+
684
+ **Metric Definitions:**
685
+ - **Cognitive Complexity**: Measures how difficult code is to understand (control flow, nesting, operators)
686
+ - Grade A: 0-5 (simple), B: 6-10 (moderate), C: 11-15 (complex), D: 16-20 (very complex), F: 21+ (extremely complex)
687
+ - **Cyclomatic Complexity**: Counts independent paths through code (branches, loops)
688
+ - Low: 1-5, Moderate: 6-10, High: 11-20, Very High: 21+
689
+ - **Instability**: Ratio of outgoing to total dependencies (I = Ce / (Ca + Ce))
690
+ - 0.0 = Stable (hard to change), 1.0 = Unstable (easy to change)
691
+ - **LCOM4**: Lack of Cohesion - number of connected components in class
692
+ - 1 = Highly cohesive (single responsibility), 2+ = Low cohesion (multiple responsibilities)
693
+
694
+ **Code Smell Severity:**
695
+ - **Error**: Critical issues blocking maintainability (God Classes, Extreme Complexity)
696
+ - **Warning**: Moderate issues needing attention (Long Methods, Deep Nesting)
697
+ - **Info**: Minor issues, cosmetic improvements (Long Parameter Lists)
698
+
699
+ **Threshold Context:**
700
+ - **Well Below**: <50% of threshold (healthy)
701
+ - **Below**: 50-100% of threshold (acceptable)
702
+ - **At Threshold**: 100-110% (monitor closely)
703
+ - **Above**: 110-150% (needs attention)
704
+ - **Well Above**: >150% (urgent action required)
705
+
706
+ **Output Format:**
707
+ Provide structured insights with:
708
+ 1. **Executive Summary**: Overall quality grade and key findings
709
+ 2. **Priority Issues**: Most critical problems to address (if any)
710
+ 3. **Specific Metrics**: Answer the user's specific question with data
711
+ 4. **Recommendations**: Actionable next steps prioritized by impact
712
+
713
+ Use markdown formatting. Be concise but thorough. Reference specific files, functions, or classes when relevant."""
714
+
715
+ # Build messages for analysis
716
+ messages = [
717
+ {"role": "system", "content": analysis_system_prompt},
718
+ {
719
+ "role": "user",
720
+ "content": f"""Analysis Data:
721
+ {analysis_context}
722
+
723
+ User Question: {query}
724
+
725
+ Please analyze the codebase and answer the user's question based on the metrics and code smell data provided.""",
726
+ },
727
+ ]
728
+
729
+ # Stream the response
730
+ console.print("\n[bold cyan]šŸ¤– Analysis:[/bold cyan]\n")
731
+
732
+ try:
733
+ # Use Rich Live for rendering streamed markdown
734
+ accumulated_response = ""
735
+ with Live(
736
+ "", console=console, auto_refresh=True, vertical_overflow="visible"
737
+ ) as live:
738
+ async for chunk in llm_client.stream_chat_completion(messages):
739
+ accumulated_response += chunk
740
+ # Update live display with accumulated markdown
741
+ live.update(Markdown(accumulated_response))
742
+
743
+ console.print() # Blank line after completion
744
+
745
+ except Exception as e:
746
+ logger.error(f"Analysis streaming failed: {e}")
747
+ print_error(f"Failed to stream analysis: {e}")
748
+
749
+ # Fallback: Use interpreter for summary
750
+ console.print("\n[yellow]⚠ Falling back to summary interpretation[/yellow]\n")
751
+ interpreter = AnalysisInterpreter()
752
+ summary = interpreter.interpret(
753
+ enhanced_export, focus="summary", verbosity="normal"
754
+ )
755
+ console.print(Markdown(summary))
756
+
757
+
758
+ async def _process_answer_query(
759
+ query: str,
760
+ llm_client: LLMClient,
761
+ search_engine: SemanticSearchEngine,
762
+ database: ChromaVectorDatabase,
763
+ session: ChatSession,
764
+ project_root: Path,
765
+ limit: int,
766
+ files: str | None,
767
+ config: Any,
768
+ ) -> None:
769
+ """Process a single answer query with agentic tool use.
770
+
771
+ Args:
772
+ query: User query
773
+ llm_client: LLM client instance
774
+ search_engine: Search engine instance
775
+ database: Vector database
776
+ session: Chat session
777
+ project_root: Project root path
778
+ limit: Max results
779
+ files: File pattern filter
780
+ config: Project config
781
+ """
782
+ # Define search tools for the LLM
783
+ tools = [
784
+ {
785
+ "type": "function",
786
+ "function": {
787
+ "name": "search_code",
788
+ "description": "Search the codebase for relevant code snippets using semantic search",
789
+ "parameters": {
790
+ "type": "object",
791
+ "properties": {
792
+ "query": {
793
+ "type": "string",
794
+ "description": "Search query to find relevant code (e.g., 'authentication logic', 'database connection', 'error handling')",
795
+ },
796
+ "limit": {
797
+ "type": "integer",
798
+ "description": "Maximum number of results to return (default: 5, max: 10)",
799
+ "default": 5,
800
+ },
801
+ },
802
+ "required": ["query"],
803
+ },
804
+ },
805
+ },
806
+ {
807
+ "type": "function",
808
+ "function": {
809
+ "name": "read_file",
810
+ "description": "Read the full content of a specific file",
811
+ "parameters": {
812
+ "type": "object",
813
+ "properties": {
814
+ "file_path": {
815
+ "type": "string",
816
+ "description": "Relative path to the file from project root",
817
+ }
818
+ },
819
+ "required": ["file_path"],
820
+ },
821
+ },
822
+ },
823
+ {
824
+ "type": "function",
825
+ "function": {
826
+ "name": "list_files",
827
+ "description": "List files in the codebase matching a pattern",
828
+ "parameters": {
829
+ "type": "object",
830
+ "properties": {
831
+ "pattern": {
832
+ "type": "string",
833
+ "description": "Glob pattern to match files (e.g., '*.py', 'src/**/*.ts', 'tests/')",
834
+ }
835
+ },
836
+ "required": ["pattern"],
837
+ },
838
+ },
839
+ },
840
+ ]
841
+
842
+ # System prompt for tool use
843
+ system_prompt = """You are a helpful code assistant with access to search tools. Use these tools to find and analyze code in the codebase.
844
+
845
+ Available tools:
846
+ - search_code: Search for relevant code using semantic search
847
+ - read_file: Read the full content of a specific file
848
+ - list_files: List files matching a pattern
849
+
850
+ Guidelines:
851
+ 1. Use search_code to find relevant code snippets
852
+ 2. Use read_file when you need to see the full file context
853
+ 3. Use list_files to understand the project structure
854
+ 4. Make multiple searches if needed to gather enough context
855
+ 5. After gathering sufficient information, provide your analysis
856
+
857
+ Always base your answers on actual code from the tools. If you can't find relevant code, say so."""
858
+
859
+ # Tool execution functions
860
+ async def execute_search_code(query_str: str, limit_val: int = 5) -> str:
861
+ """Execute search_code tool."""
862
+ try:
863
+ limit_val = min(limit_val, 10) # Cap at 10
864
+ async with database:
865
+ results = await search_engine.search(
866
+ query=query_str,
867
+ limit=limit_val,
868
+ similarity_threshold=config.similarity_threshold,
869
+ include_context=True,
870
+ )
871
+
872
+ # Post-filter by file pattern if specified
873
+ if files and results:
874
+ filtered_results = []
875
+ for result in results:
876
+ try:
877
+ rel_path = str(result.file_path.relative_to(project_root))
878
+ except ValueError:
879
+ rel_path = str(result.file_path)
880
+
881
+ if fnmatch(rel_path, files) or fnmatch(
882
+ os.path.basename(rel_path), files
883
+ ):
884
+ filtered_results.append(result)
885
+ results = filtered_results
886
+
887
+ if not results:
888
+ return "No results found for this query."
889
+
890
+ # Format results
891
+ result_parts = []
892
+ for i, result in enumerate(results, 1):
893
+ try:
894
+ rel_path = str(result.file_path.relative_to(project_root))
895
+ except ValueError:
896
+ rel_path = str(result.file_path)
897
+
898
+ result_parts.append(
899
+ f"[Result {i}: {rel_path}]\n"
900
+ f"Location: {result.location}\n"
901
+ f"Lines {result.start_line}-{result.end_line}\n"
902
+ f"Similarity: {result.similarity_score:.3f}\n"
903
+ f"```\n{result.content}\n```\n"
904
+ )
905
+ return "\n".join(result_parts)
906
+
907
+ except Exception as e:
908
+ logger.error(f"search_code tool failed: {e}")
909
+ return f"Error searching code: {e}"
910
+
911
+ async def execute_read_file(file_path: str) -> str:
912
+ """Execute read_file tool."""
913
+ try:
914
+ # Normalize path
915
+ if file_path.startswith("/"):
916
+ full_path = Path(file_path)
917
+ else:
918
+ full_path = project_root / file_path
919
+
920
+ # Security check: file must be within project
921
+ try:
922
+ full_path.relative_to(project_root)
923
+ except ValueError:
924
+ return f"Error: File must be within project root: {project_root}"
925
+
926
+ if not full_path.exists():
927
+ return f"Error: File not found: {file_path}"
928
+
929
+ if not full_path.is_file():
930
+ return f"Error: Not a file: {file_path}"
931
+
932
+ # Read file with size limit
933
+ max_size = 100_000 # 100KB
934
+ file_size = full_path.stat().st_size
935
+ if file_size > max_size:
936
+ return f"Error: File too large ({file_size} bytes). Use search_code instead."
937
+
938
+ content = full_path.read_text(errors="replace")
939
+ return f"File: {file_path}\n```\n{content}\n```"
940
+
941
+ except Exception as e:
942
+ logger.error(f"read_file tool failed: {e}")
943
+ return f"Error reading file: {e}"
944
+
945
+ async def execute_list_files(pattern: str) -> str:
946
+ """Execute list_files tool."""
947
+ try:
948
+ from glob import glob
949
+
950
+ # Use glob to find matching files
951
+ matches = glob(str(project_root / pattern), recursive=True)
952
+
953
+ if not matches:
954
+ return f"No files found matching pattern: {pattern}"
955
+
956
+ # Get relative paths and limit results
957
+ rel_paths = []
958
+ for match in matches[:50]: # Limit to 50 files
959
+ try:
960
+ rel_path = Path(match).relative_to(project_root)
961
+ rel_paths.append(str(rel_path))
962
+ except ValueError:
963
+ continue
964
+
965
+ if not rel_paths:
966
+ return f"No files found matching pattern: {pattern}"
967
+
968
+ return f"Files matching '{pattern}':\n" + "\n".join(
969
+ f"- {p}" for p in sorted(rel_paths)
970
+ )
971
+
972
+ except Exception as e:
973
+ logger.error(f"list_files tool failed: {e}")
974
+ return f"Error listing files: {e}"
975
+
976
+ # Get conversation history
977
+ conversation_history = session.get_messages()[1:] # Skip system prompt
978
+
979
+ # Build messages: system + history + current query
980
+ messages = [{"role": "system", "content": system_prompt}]
981
+ messages.extend(conversation_history)
982
+ messages.append({"role": "user", "content": query})
983
+
984
+ # Agentic loop
985
+ max_iterations = 25
986
+ for _iteration in range(max_iterations):
987
+ try:
988
+ response = await llm_client.chat_with_tools(messages, tools)
989
+
990
+ # Extract message from response
991
+ choice = response.get("choices", [{}])[0]
992
+ message = choice.get("message", {})
993
+
994
+ # Check for tool calls
995
+ tool_calls = message.get("tool_calls", [])
996
+
997
+ if tool_calls:
998
+ # Add assistant message with tool calls
999
+ messages.append(message)
1000
+
1001
+ # Execute each tool call
1002
+ for tool_call in tool_calls:
1003
+ tool_id = tool_call.get("id")
1004
+ function = tool_call.get("function", {})
1005
+ function_name = function.get("name")
1006
+ arguments_str = function.get("arguments", "{}")
1007
+
1008
+ # Parse arguments
1009
+ try:
1010
+ import json
1011
+
1012
+ arguments = json.loads(arguments_str)
1013
+ except json.JSONDecodeError:
1014
+ arguments = {}
1015
+
1016
+ # Display tool usage
1017
+ console.print(
1018
+ f"\n[dim]šŸ”§ Using tool: {function_name}({', '.join(f'{k}={repr(v)}' for k, v in arguments.items())})[/dim]"
1019
+ )
1020
+
1021
+ # Execute tool
1022
+ if function_name == "search_code":
1023
+ result = await execute_search_code(
1024
+ arguments.get("query", ""),
1025
+ arguments.get("limit", 5),
1026
+ )
1027
+ console.print(
1028
+ f"[dim] Found {len(result.split('[Result')) - 1} results[/dim]"
1029
+ )
1030
+ elif function_name == "read_file":
1031
+ result = await execute_read_file(arguments.get("file_path", ""))
1032
+ console.print("[dim] Read file[/dim]")
1033
+ elif function_name == "list_files":
1034
+ result = await execute_list_files(arguments.get("pattern", ""))
1035
+ console.print("[dim] Listed files[/dim]")
1036
+ else:
1037
+ result = f"Error: Unknown tool: {function_name}"
1038
+
1039
+ # Add tool result to messages
1040
+ messages.append(
1041
+ {
1042
+ "role": "tool",
1043
+ "tool_call_id": tool_id,
1044
+ "content": result,
1045
+ }
1046
+ )
1047
+
1048
+ else:
1049
+ # No tool calls - final response
1050
+ final_content = message.get("content", "")
1051
+
1052
+ if not final_content:
1053
+ print_error("LLM returned empty response")
1054
+ return
1055
+
1056
+ # Stream the final response
1057
+ console.print("\n[bold cyan]šŸ¤– Assistant:[/bold cyan]\n")
1058
+
1059
+ # Use Rich Live for rendering
1060
+ with Live("", console=console, auto_refresh=True) as live:
1061
+ live.update(Markdown(final_content))
1062
+
1063
+ # Add to session history
1064
+ session.add_message("user", query)
1065
+ session.add_message("assistant", final_content)
1066
+
1067
+ return
1068
+
1069
+ except Exception as e:
1070
+ logger.error(f"Tool execution loop failed: {e}")
1071
+ print_error(f"Error: {e}")
1072
+ return
1073
+
1074
+ # Max iterations reached
1075
+ print_warning(
1076
+ "\n⚠ Maximum iterations reached. The assistant may not have gathered enough information."
1077
+ )
1078
+
1079
+
1080
+ async def run_chat_search(
1081
+ project_root: Path,
1082
+ query: str,
1083
+ limit: int = 5,
1084
+ model: str | None = None,
1085
+ provider: str | None = None,
1086
+ timeout: float = 30.0,
1087
+ json_output: bool = False,
1088
+ files: str | None = None,
1089
+ think: bool = False,
1090
+ ) -> None:
1091
+ """Run LLM-powered chat search.
1092
+
1093
+ Implementation Flow:
1094
+ 1. Initialize LLM client and validate API key
1095
+ 2. Generate 2-3 targeted search queries from natural language
1096
+ 3. Execute each search query against vector database
1097
+ 4. Have LLM analyze all results and select most relevant ones
1098
+ 5. Display results with explanations
1099
+
1100
+ Args:
1101
+ project_root: Project root directory
1102
+ query: Natural language query from user
1103
+ limit: Maximum number of results to return
1104
+ model: Model to use (optional, defaults based on provider)
1105
+ provider: LLM provider ('openai' or 'openrouter', auto-detect if None)
1106
+ timeout: API timeout in seconds
1107
+ json_output: Whether to output JSON format
1108
+ files: Optional glob pattern to filter files (e.g., '*.py', 'src/*.js')
1109
+ think: Use advanced "thinking" model for complex queries
1110
+ """
1111
+ # Check for API keys (environment variable or config file)
1112
+ from ...core.config_utils import (
1113
+ get_openai_api_key,
1114
+ get_openrouter_api_key,
1115
+ get_preferred_llm_provider,
1116
+ )
1117
+
1118
+ config_dir = project_root / ".mcp-vector-search"
1119
+ openai_key = get_openai_api_key(config_dir)
1120
+ openrouter_key = get_openrouter_api_key(config_dir)
1121
+
1122
+ # Determine which provider to use
1123
+ if provider:
1124
+ # Explicit provider specified
1125
+ if provider == "openai" and not openai_key:
1126
+ print_error("OpenAI API key not found.")
1127
+ print_info("\n[bold]To use OpenAI:[/bold]")
1128
+ print_info(
1129
+ "1. Get an API key from [cyan]https://platform.openai.com/api-keys[/cyan]"
1130
+ )
1131
+ print_info("2. Set environment variable:")
1132
+ print_info(" [yellow]export OPENAI_API_KEY='your-key'[/yellow]")
1133
+ print_info("")
1134
+ print_info("Or run: [cyan]mcp-vector-search setup[/cyan]")
1135
+ raise typer.Exit(1)
1136
+ elif provider == "openrouter" and not openrouter_key:
1137
+ print_error("OpenRouter API key not found.")
1138
+ print_info("\n[bold]To use OpenRouter:[/bold]")
1139
+ print_info("1. Get an API key from [cyan]https://openrouter.ai/keys[/cyan]")
1140
+ print_info("2. Set environment variable:")
1141
+ print_info(" [yellow]export OPENROUTER_API_KEY='your-key'[/yellow]")
1142
+ print_info("")
1143
+ print_info("Or run: [cyan]mcp-vector-search setup[/cyan]")
1144
+ raise typer.Exit(1)
1145
+ else:
1146
+ # Auto-detect provider
1147
+ preferred_provider = get_preferred_llm_provider(config_dir)
1148
+
1149
+ if preferred_provider == "openai" and openai_key:
1150
+ provider = "openai"
1151
+ elif preferred_provider == "openrouter" and openrouter_key:
1152
+ provider = "openrouter"
1153
+ elif openai_key:
1154
+ provider = "openai"
1155
+ elif openrouter_key:
1156
+ provider = "openrouter"
1157
+ else:
1158
+ console.print() # Blank line for spacing
1159
+ show_api_key_help()
1160
+ raise typer.Exit(1)
1161
+
1162
+ # Load project configuration
1163
+ project_manager = ProjectManager(project_root)
1164
+
1165
+ if not project_manager.is_initialized():
1166
+ raise ProjectNotFoundError(
1167
+ f"Project not initialized at {project_root}. Run 'mcp-vector-search init' first."
1168
+ )
1169
+
1170
+ config = project_manager.load_config()
1171
+
1172
+ # Initialize LLM client
1173
+ try:
1174
+ llm_client = LLMClient(
1175
+ openai_api_key=openai_key,
1176
+ openrouter_api_key=openrouter_key,
1177
+ model=model,
1178
+ provider=provider,
1179
+ timeout=timeout,
1180
+ think=think,
1181
+ )
1182
+ provider_display = llm_client.provider.capitalize()
1183
+ model_info = f"{llm_client.model}"
1184
+ if think:
1185
+ model_info += " [bold magenta](thinking mode)[/bold magenta]"
1186
+ print_success(f"Connected to {provider_display}: {model_info}")
1187
+ except ValueError as e:
1188
+ print_error(str(e))
1189
+ raise typer.Exit(1)
1190
+
1191
+ # Step 1: Generate search queries from natural language
1192
+ console.print(f"\n[cyan]šŸ’­ Analyzing query:[/cyan] [white]{query}[/white]")
1193
+
1194
+ try:
1195
+ search_queries = await llm_client.generate_search_queries(query, limit=3)
1196
+
1197
+ if not search_queries:
1198
+ print_error("Failed to generate search queries from your question.")
1199
+ raise typer.Exit(1)
1200
+
1201
+ console.print(
1202
+ f"\n[cyan]šŸ” Generated {len(search_queries)} search queries:[/cyan]"
1203
+ )
1204
+ for i, sq in enumerate(search_queries, 1):
1205
+ console.print(f" {i}. [yellow]{sq}[/yellow]")
1206
+
1207
+ except SearchError as e:
1208
+ print_error(f"Failed to generate queries: {e}")
1209
+ raise typer.Exit(1)
1210
+
1211
+ # Step 2: Execute each search query
1212
+ console.print("\n[cyan]šŸ”Ž Searching codebase...[/cyan]")
1213
+
1214
+ embedding_function, _ = create_embedding_function(config.embedding_model)
1215
+ database = ChromaVectorDatabase(
1216
+ persist_directory=config.index_path,
1217
+ embedding_function=embedding_function,
1218
+ )
1219
+
1220
+ search_engine = SemanticSearchEngine(
1221
+ database=database,
1222
+ project_root=project_root,
1223
+ similarity_threshold=config.similarity_threshold,
1224
+ )
1225
+
1226
+ # Execute all searches
1227
+ search_results = {}
1228
+ total_results = 0
1229
+
1230
+ try:
1231
+ async with database:
1232
+ for search_query in search_queries:
1233
+ results = await search_engine.search(
1234
+ query=search_query,
1235
+ limit=limit * 2, # Get more results for LLM to analyze
1236
+ similarity_threshold=config.similarity_threshold,
1237
+ include_context=True,
1238
+ )
1239
+
1240
+ # Post-filter results by file pattern if specified
1241
+ if files and results:
1242
+ filtered_results = []
1243
+ for result in results:
1244
+ # Get relative path from project root
1245
+ try:
1246
+ rel_path = str(result.file_path.relative_to(project_root))
1247
+ except ValueError:
1248
+ # If file is outside project root, use absolute path
1249
+ rel_path = str(result.file_path)
1250
+
1251
+ # Match against glob pattern (both full path and basename)
1252
+ if fnmatch(rel_path, files) or fnmatch(
1253
+ os.path.basename(rel_path), files
1254
+ ):
1255
+ filtered_results.append(result)
1256
+ results = filtered_results
1257
+
1258
+ search_results[search_query] = results
1259
+ total_results += len(results)
1260
+
1261
+ console.print(
1262
+ f" • [yellow]{search_query}[/yellow]: {len(results)} results"
1263
+ )
1264
+
1265
+ except Exception as e:
1266
+ logger.error(f"Search execution failed: {e}")
1267
+ print_error(f"Search failed: {e}")
1268
+ raise typer.Exit(1)
1269
+
1270
+ if total_results == 0:
1271
+ print_warning("\nāš ļø No results found for any search query.")
1272
+ print_info("\n[bold]Suggestions:[/bold]")
1273
+ print_info(" • Try rephrasing your question")
1274
+ print_info(" • Use more general terms")
1275
+ print_info(
1276
+ " • Check if relevant files are indexed with [cyan]mcp-vector-search status[/cyan]"
1277
+ )
1278
+ raise typer.Exit(0)
1279
+
1280
+ # Step 3: Have LLM analyze and rank results
1281
+ console.print(f"\n[cyan]šŸ¤– Analyzing {total_results} results...[/cyan]")
1282
+
1283
+ try:
1284
+ ranked_results = await llm_client.analyze_and_rank_results(
1285
+ original_query=query,
1286
+ search_results=search_results,
1287
+ top_n=limit,
1288
+ )
1289
+
1290
+ if not ranked_results:
1291
+ print_warning("\nāš ļø LLM could not identify relevant results.")
1292
+ raise typer.Exit(0)
1293
+
1294
+ except SearchError as e:
1295
+ print_error(f"Result analysis failed: {e}")
1296
+ # Fallback: show raw search results
1297
+ print_warning("\nShowing raw search results instead...")
1298
+ await _show_fallback_results(search_results, limit)
1299
+ raise typer.Exit(1)
1300
+
1301
+ # Step 4: Display results with explanations
1302
+ if json_output:
1303
+ await _display_json_results(ranked_results)
1304
+ else:
1305
+ await _display_rich_results(ranked_results, query)
1306
+
1307
+
1308
+ async def _display_rich_results(
1309
+ ranked_results: list[dict],
1310
+ original_query: str,
1311
+ ) -> None:
1312
+ """Display results in rich formatted output.
1313
+
1314
+ Args:
1315
+ ranked_results: List of ranked results with explanations
1316
+ original_query: Original user query
1317
+ """
1318
+ from rich.panel import Panel
1319
+ from rich.syntax import Syntax
1320
+
1321
+ console.print(
1322
+ f"\n[bold cyan]šŸŽÆ Top Results for:[/bold cyan] [white]{original_query}[/white]\n"
1323
+ )
1324
+
1325
+ for i, item in enumerate(ranked_results, 1):
1326
+ result = item["result"]
1327
+ relevance = item["relevance"]
1328
+ explanation = item["explanation"]
1329
+ query = item["query"]
1330
+
1331
+ # Determine relevance emoji and color
1332
+ if relevance == "High":
1333
+ relevance_emoji = "🟢"
1334
+ relevance_color = "green"
1335
+ elif relevance == "Medium":
1336
+ relevance_emoji = "🟔"
1337
+ relevance_color = "yellow"
1338
+ else:
1339
+ relevance_emoji = "šŸ”“"
1340
+ relevance_color = "red"
1341
+
1342
+ # Header with result number and file
1343
+ console.print(f"[bold]šŸ“ Result {i} of {len(ranked_results)}[/bold]")
1344
+ console.print(
1345
+ f"[cyan]šŸ“‚ {result.file_path.relative_to(result.file_path.parent.parent)}[/cyan]"
1346
+ )
1347
+
1348
+ # Relevance and explanation
1349
+ console.print(
1350
+ f"\n{relevance_emoji} [bold {relevance_color}]Relevance: {relevance}[/bold {relevance_color}]"
1351
+ )
1352
+ console.print(f"[dim]Search query: {query}[/dim]")
1353
+ console.print(f"\nšŸ’” [italic]{explanation}[/italic]\n")
1354
+
1355
+ # Code snippet with syntax highlighting
1356
+ file_ext = result.file_path.suffix.lstrip(".")
1357
+ code_syntax = Syntax(
1358
+ result.content,
1359
+ lexer=file_ext or "python",
1360
+ theme="monokai",
1361
+ line_numbers=True,
1362
+ start_line=result.start_line,
1363
+ )
1364
+
1365
+ panel = Panel(
1366
+ code_syntax,
1367
+ title=f"[bold]{result.function_name or result.class_name or 'Code'}[/bold]",
1368
+ border_style="cyan",
1369
+ )
1370
+ console.print(panel)
1371
+
1372
+ # Metadata
1373
+ metadata = []
1374
+ if result.function_name:
1375
+ metadata.append(f"Function: [cyan]{result.function_name}[/cyan]")
1376
+ if result.class_name:
1377
+ metadata.append(f"Class: [cyan]{result.class_name}[/cyan]")
1378
+ metadata.append(f"Lines: [cyan]{result.start_line}-{result.end_line}[/cyan]")
1379
+ metadata.append(f"Similarity: [cyan]{result.similarity_score:.3f}[/cyan]")
1380
+
1381
+ console.print("[dim]" + " | ".join(metadata) + "[/dim]")
1382
+ console.print() # Blank line between results
1383
+
1384
+ # Footer with tips
1385
+ console.print("[dim]─" * 80 + "[/dim]")
1386
+ console.print(
1387
+ "\n[dim]šŸ’” Tip: Try different phrasings or add more specific terms for better results[/dim]"
1388
+ )
1389
+
1390
+
1391
+ async def _display_json_results(ranked_results: list[dict]) -> None:
1392
+ """Display results in JSON format.
1393
+
1394
+ Args:
1395
+ ranked_results: List of ranked results with explanations
1396
+ """
1397
+ from ..output import print_json
1398
+
1399
+ json_data = []
1400
+ for item in ranked_results:
1401
+ result = item["result"]
1402
+ json_data.append(
1403
+ {
1404
+ "file": str(result.file_path),
1405
+ "start_line": result.start_line,
1406
+ "end_line": result.end_line,
1407
+ "function_name": result.function_name,
1408
+ "class_name": result.class_name,
1409
+ "content": result.content,
1410
+ "similarity_score": result.similarity_score,
1411
+ "relevance": item["relevance"],
1412
+ "explanation": item["explanation"],
1413
+ "search_query": item["query"],
1414
+ }
1415
+ )
1416
+
1417
+ print_json(json_data, title="Chat Search Results")
1418
+
1419
+
1420
+ async def _show_fallback_results(
1421
+ search_results: dict[str, list],
1422
+ limit: int,
1423
+ ) -> None:
1424
+ """Show fallback results when LLM analysis fails.
1425
+
1426
+ Args:
1427
+ search_results: Dictionary of search queries to results
1428
+ limit: Number of results to show
1429
+ """
1430
+ from ..output import print_search_results
1431
+
1432
+ # Flatten and deduplicate results
1433
+ all_results = []
1434
+ seen_files = set()
1435
+
1436
+ for results in search_results.values():
1437
+ for result in results:
1438
+ file_key = (result.file_path, result.start_line)
1439
+ if file_key not in seen_files:
1440
+ all_results.append(result)
1441
+ seen_files.add(file_key)
1442
+
1443
+ # Sort by similarity score
1444
+ all_results.sort(key=lambda r: r.similarity_score, reverse=True)
1445
+
1446
+ # Show top N
1447
+ print_search_results(
1448
+ results=all_results[:limit],
1449
+ query="Combined search results",
1450
+ show_content=True,
1451
+ )
1452
+
1453
+
1454
+ if __name__ == "__main__":
1455
+ chat_app()