srcodex 0.2.0__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.
- srcodex/__init__.py +0 -0
- srcodex/backend/__init__.py +0 -0
- srcodex/backend/chat.py +79 -0
- srcodex/backend/main.py +98 -0
- srcodex/backend/services/__init__.py +0 -0
- srcodex/backend/services/claude_service.py +754 -0
- srcodex/backend/services/config_loader.py +113 -0
- srcodex/backend/services/file_access_tools.py +279 -0
- srcodex/backend/services/file_tree.py +480 -0
- srcodex/backend/services/graph_tools.py +874 -0
- srcodex/backend/services/logger_setup.py +91 -0
- srcodex/backend/services/session_manager.py +81 -0
- srcodex/backend/services/status_tracker.py +91 -0
- srcodex/cli.py +255 -0
- srcodex/core/__init__.py +0 -0
- srcodex/core/config.py +113 -0
- srcodex/core/logger.py +23 -0
- srcodex/indexer/__init__.py +0 -0
- srcodex/indexer/cscope_client.py +183 -0
- srcodex/indexer/ctags_compat.py +223 -0
- srcodex/indexer/ctags_parser.py +456 -0
- srcodex/indexer/explorer.py +135 -0
- srcodex/indexer/field_access_analyzer.py +436 -0
- srcodex/indexer/indexer.py +664 -0
- srcodex/indexer/reference_ingestor.py +293 -0
- srcodex/indexer/reference_resolver.py +544 -0
- srcodex/tui/__init__.py +0 -0
- srcodex/tui/app.py +103 -0
- srcodex/tui/app.tcss +24 -0
- srcodex/tui/components/__init__.py +0 -0
- srcodex/tui/components/bars/__init__.py +0 -0
- srcodex/tui/components/bars/chat_header.py +48 -0
- srcodex/tui/components/bars/code_tab_bar.py +157 -0
- srcodex/tui/components/bars/footer_bar.py +128 -0
- srcodex/tui/components/bars/left_tab.py +54 -0
- srcodex/tui/components/logger.py +57 -0
- srcodex/tui/components/panels/__init__.py +0 -0
- srcodex/tui/components/panels/chat_panel.py +523 -0
- srcodex/tui/components/panels/code_panel.py +229 -0
- srcodex/tui/components/panels/side_panel.py +128 -0
- srcodex/tui/components/views/__init__.py +0 -0
- srcodex/tui/components/views/explorer_view.py +20 -0
- srcodex/tui/components/views/search_view.py +148 -0
- srcodex/tui/components/widgets/__init__.py +0 -0
- srcodex/tui/components/widgets/file_browser.py +16 -0
- srcodex/tui/components/widgets/find_box.py +85 -0
- srcodex-0.2.0.dist-info/METADATA +170 -0
- srcodex-0.2.0.dist-info/RECORD +52 -0
- srcodex-0.2.0.dist-info/WHEEL +5 -0
- srcodex-0.2.0.dist-info/entry_points.txt +2 -0
- srcodex-0.2.0.dist-info/licenses/LICENSE +21 -0
- srcodex-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logging configuration for srcodex
|
|
3
|
+
Sets up file logging to .srcodex/.debug/backend.log
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from .config_loader import get_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ColoredFormatter(logging.Formatter):
|
|
11
|
+
"""Custom formatter with ANSI colors"""
|
|
12
|
+
|
|
13
|
+
COLORS = {
|
|
14
|
+
'DEBUG': '\033[36m', # Cyan
|
|
15
|
+
'INFO': '\033[37m', # White
|
|
16
|
+
'WARNING': '\033[33m', # Yellow (entire line)
|
|
17
|
+
'ERROR': '\033[31m', # Red (entire line)
|
|
18
|
+
'CRITICAL': '\033[91m', # Bright Red (entire line)
|
|
19
|
+
}
|
|
20
|
+
RESET = '\033[0m'
|
|
21
|
+
BOLD = '\033[1m'
|
|
22
|
+
|
|
23
|
+
def format(self, record):
|
|
24
|
+
# Format the message first
|
|
25
|
+
formatted = super().format(record)
|
|
26
|
+
|
|
27
|
+
# Color entire line based on level
|
|
28
|
+
if record.levelname in ['WARNING', 'ERROR', 'CRITICAL']:
|
|
29
|
+
color = self.COLORS.get(record.levelname, self.RESET)
|
|
30
|
+
return f"{color}{formatted}{self.RESET}"
|
|
31
|
+
|
|
32
|
+
# Special handling for iteration markers (bold white)
|
|
33
|
+
if 'Iteration' in record.msg:
|
|
34
|
+
return f"{self.BOLD}{formatted}{self.RESET}"
|
|
35
|
+
|
|
36
|
+
# Tool calls in magenta
|
|
37
|
+
if 'Tool #' in record.msg:
|
|
38
|
+
return f"\033[35m{formatted}{self.RESET}"
|
|
39
|
+
|
|
40
|
+
return formatted
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def setup_backend_logging():
|
|
44
|
+
"""
|
|
45
|
+
Configure backend logging to write to .srcodex/.debug/backend.log
|
|
46
|
+
Creates the .debug directory if it doesn't exist
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
config = get_config()
|
|
50
|
+
debug_dir = config.project_root / ".srcodex" / ".debug"
|
|
51
|
+
debug_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
log_file = debug_dir / "backend.log"
|
|
54
|
+
|
|
55
|
+
# Get root logger
|
|
56
|
+
root_logger = logging.getLogger()
|
|
57
|
+
root_logger.setLevel(logging.INFO)
|
|
58
|
+
|
|
59
|
+
# Clear any existing handlers to avoid duplicates
|
|
60
|
+
root_logger.handlers.clear()
|
|
61
|
+
|
|
62
|
+
# File handler - NO colors (plain text for file)
|
|
63
|
+
file_handler = logging.FileHandler(log_file, mode='a')
|
|
64
|
+
file_handler.setLevel(logging.INFO)
|
|
65
|
+
file_handler.setFormatter(
|
|
66
|
+
logging.Formatter('%(asctime)s [%(levelname)s] %(name)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
|
67
|
+
)
|
|
68
|
+
root_logger.addHandler(file_handler)
|
|
69
|
+
|
|
70
|
+
# Console handler - WITH colors (for terminal output)
|
|
71
|
+
console_handler = logging.StreamHandler()
|
|
72
|
+
console_handler.setLevel(logging.INFO)
|
|
73
|
+
colored_formatter = ColoredFormatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S')
|
|
74
|
+
console_handler.setFormatter(colored_formatter)
|
|
75
|
+
root_logger.addHandler(console_handler)
|
|
76
|
+
|
|
77
|
+
logger = logging.getLogger(__name__)
|
|
78
|
+
logger.info(f"Backend logging initialized: {log_file}")
|
|
79
|
+
|
|
80
|
+
return log_file
|
|
81
|
+
|
|
82
|
+
except Exception as e:
|
|
83
|
+
# Fallback to console-only logging if config fails
|
|
84
|
+
logging.basicConfig(
|
|
85
|
+
level=logging.INFO,
|
|
86
|
+
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
|
|
87
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
|
88
|
+
)
|
|
89
|
+
logger = logging.getLogger(__name__)
|
|
90
|
+
logger.warning(f"Could not set up file logging: {e}")
|
|
91
|
+
return None
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversation Session Manager
|
|
3
|
+
Handles saving and loading conversation history across TUI restarts.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import List, Dict, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionManager:
|
|
12
|
+
"""Manages persistent conversation sessions"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, project_root: str):
|
|
15
|
+
self.project_root = Path(project_root)
|
|
16
|
+
self.sessions_dir = self.project_root / ".srcodex" / "conversations"
|
|
17
|
+
self.current_session_file = self.sessions_dir / "latest.json"
|
|
18
|
+
|
|
19
|
+
# Create directories if they don't exist
|
|
20
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
def save_session(self, messages: List[Dict], metadata: Optional[Dict] = None):
|
|
23
|
+
"""
|
|
24
|
+
Save conversation history to disk
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
messages: List of conversation messages [{"role": "user", "content": "..."}]
|
|
28
|
+
metadata: Optional metadata (token counts, timestamps, etc.)
|
|
29
|
+
"""
|
|
30
|
+
session_data = {
|
|
31
|
+
"created_at": datetime.now().isoformat(),
|
|
32
|
+
"message_count": len(messages),
|
|
33
|
+
"metadata": metadata or {},
|
|
34
|
+
"messages": messages
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
with open(self.current_session_file, 'w') as f:
|
|
38
|
+
json.dump(session_data, f, indent=2)
|
|
39
|
+
|
|
40
|
+
def load_session(self) -> List[Dict]:
|
|
41
|
+
"""
|
|
42
|
+
Load last conversation history from disk
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
List of messages, or empty list if no session exists
|
|
46
|
+
"""
|
|
47
|
+
if not self.current_session_file.exists():
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
with open(self.current_session_file, 'r') as f:
|
|
52
|
+
session_data = json.load(f)
|
|
53
|
+
return session_data.get("messages", [])
|
|
54
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
def load_metadata(self) -> Dict:
|
|
58
|
+
"""
|
|
59
|
+
Load session metadata from disk
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Dict of metadata, or empty dict if no session exists
|
|
63
|
+
"""
|
|
64
|
+
if not self.current_session_file.exists():
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
with open(self.current_session_file, 'r') as f:
|
|
69
|
+
session_data = json.load(f)
|
|
70
|
+
return session_data.get("metadata", {})
|
|
71
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
72
|
+
return {}
|
|
73
|
+
|
|
74
|
+
def clear_session(self):
|
|
75
|
+
"""Delete current session file"""
|
|
76
|
+
if self.current_session_file.exists():
|
|
77
|
+
self.current_session_file.unlink()
|
|
78
|
+
|
|
79
|
+
def session_exists(self) -> bool:
|
|
80
|
+
"""Check if a saved session exists"""
|
|
81
|
+
return self.current_session_file.exists()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Status tracker for Claude query execution
|
|
3
|
+
|
|
4
|
+
Tracks iteration progress, tool calls, and elapsed time for live UI updates.
|
|
5
|
+
"""
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional, Dict, Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StatusTracker:
|
|
11
|
+
"""Tracks query execution status for live UI updates"""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.query_start_time: Optional[float] = None
|
|
15
|
+
self.current_iteration: int = 0
|
|
16
|
+
self.max_iterations: int = 5
|
|
17
|
+
self.current_status: str = ""
|
|
18
|
+
self.is_active: bool = False
|
|
19
|
+
|
|
20
|
+
def start_query(self):
|
|
21
|
+
"""Start tracking a new query"""
|
|
22
|
+
self.query_start_time = time.time()
|
|
23
|
+
self.current_iteration = 0
|
|
24
|
+
self.current_status = ""
|
|
25
|
+
self.is_active = True
|
|
26
|
+
|
|
27
|
+
def end_query(self):
|
|
28
|
+
"""Stop tracking the query"""
|
|
29
|
+
self.is_active = False
|
|
30
|
+
self.current_status = ""
|
|
31
|
+
|
|
32
|
+
def start_iteration(self, iteration: int):
|
|
33
|
+
"""Start a new iteration"""
|
|
34
|
+
self.current_iteration = iteration
|
|
35
|
+
|
|
36
|
+
def set_tool_status(self, tool_name: str, total_tools: int = 1):
|
|
37
|
+
"""
|
|
38
|
+
Update status with current tool being called
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
tool_name: Name of the tool (e.g., 'search_symbols')
|
|
42
|
+
total_tools: Total number of tools in this batch
|
|
43
|
+
"""
|
|
44
|
+
if total_tools > 1:
|
|
45
|
+
self.current_status = f"Calling {tool_name}() +{total_tools-1} more"
|
|
46
|
+
else:
|
|
47
|
+
self.current_status = f"Calling {tool_name}()"
|
|
48
|
+
|
|
49
|
+
def set_processing_status(self, total_tools: int):
|
|
50
|
+
"""Update status to show processing multiple tools"""
|
|
51
|
+
self.current_status = f"Processing {total_tools} tools..."
|
|
52
|
+
|
|
53
|
+
def set_preparing_answer(self):
|
|
54
|
+
"""Update status to show Claude is preparing the answer"""
|
|
55
|
+
self.current_status = "Preparing answer..."
|
|
56
|
+
|
|
57
|
+
def set_complete(self):
|
|
58
|
+
"""Mark query as complete"""
|
|
59
|
+
self.current_status = "Complete"
|
|
60
|
+
|
|
61
|
+
def get_elapsed_seconds(self) -> int:
|
|
62
|
+
"""Get elapsed time in whole seconds"""
|
|
63
|
+
if not self.query_start_time:
|
|
64
|
+
return 0
|
|
65
|
+
return int(time.time() - self.query_start_time)
|
|
66
|
+
|
|
67
|
+
def get_status_message(self) -> Dict[str, Any]:
|
|
68
|
+
"""
|
|
69
|
+
Get current status as a dict for streaming to frontend
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
{"type": "status", "content": "Calling search_symbols() +34 more", "elapsed": 5}
|
|
73
|
+
"""
|
|
74
|
+
return {
|
|
75
|
+
"type": "status",
|
|
76
|
+
"content": self.current_status,
|
|
77
|
+
"elapsed": self.get_elapsed_seconds()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def format_status_line(self) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Format status line for display
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
"Calling search_symbols() +34 more | 5s"
|
|
86
|
+
"""
|
|
87
|
+
if not self.is_active or not self.current_status:
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
elapsed = self.get_elapsed_seconds()
|
|
91
|
+
return f"{self.current_status} | {elapsed}s"
|
srcodex/cli.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
srcodex CLI - Semantic code explorer with AI-powered analysis
|
|
4
|
+
"""
|
|
5
|
+
import click
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from srcodex.tui.app import SrcodexApp
|
|
15
|
+
from srcodex.core.config import get_config
|
|
16
|
+
from srcodex.indexer.indexer import Indexer
|
|
17
|
+
from srcodex.indexer.field_access_analyzer import FieldAccessAnalyzer
|
|
18
|
+
from srcodex.indexer.reference_ingestor import ReferenceIngestor
|
|
19
|
+
from srcodex.indexer.reference_resolver import ReferenceResolver
|
|
20
|
+
|
|
21
|
+
@click.command()
|
|
22
|
+
@click.argument('path', default='.', type=click.Path(exists=True))
|
|
23
|
+
@click.option('--reindex', is_flag=True, help='Force re-indexing')
|
|
24
|
+
@click.option('--debug', is_flag=True, help='Enable debug logging')
|
|
25
|
+
def main(path, reindex, debug):
|
|
26
|
+
"""
|
|
27
|
+
Launch srcodex TUI
|
|
28
|
+
|
|
29
|
+
EXAMPLES:
|
|
30
|
+
srcodex # Index current directory and launch
|
|
31
|
+
srcodex /path/to/code # Index specific directory
|
|
32
|
+
srcodex --reindex # Force re-index and launch
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
click.echo("srcodex v0.1.0 - Semantic code explorer")
|
|
36
|
+
click.echo()
|
|
37
|
+
|
|
38
|
+
project_path = Path(path).resolve()
|
|
39
|
+
srcodex_dir = project_path / ".srcodex"
|
|
40
|
+
|
|
41
|
+
# Check if .srcodex exists
|
|
42
|
+
if not srcodex_dir.exists() or reindex:
|
|
43
|
+
if not reindex:
|
|
44
|
+
# Prompt user
|
|
45
|
+
if not click.confirm(f"No .srcodex/ found in {project_path}\nIndex this directory?"):
|
|
46
|
+
click.echo("Cancelled.")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
click.echo(f"\nIndexing {project_path}...")
|
|
50
|
+
click.echo("This may take a few minutes for large codebases...\n")
|
|
51
|
+
|
|
52
|
+
# Run indexer
|
|
53
|
+
try:
|
|
54
|
+
run_indexer(project_path, debug)
|
|
55
|
+
click.echo("\nIndexing complete!")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
click.echo(f"\nError during indexing: {e}", err=True)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
# Check for API key
|
|
61
|
+
if not check_api_key():
|
|
62
|
+
click.echo("\nError: No API key found!", err=True)
|
|
63
|
+
click.echo("Please set either:")
|
|
64
|
+
click.echo(" - ANTHROPIC_API_KEY (for public API)")
|
|
65
|
+
click.echo(" - AMD_LLM_API_KEY (for enterprise gateway)")
|
|
66
|
+
click.echo("\nSee .env.example for configuration details")
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
# Start backend server
|
|
70
|
+
click.echo("\nStarting backend server...")
|
|
71
|
+
backend_process = start_backend(project_path, debug)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
# Launch TUI (blocking)
|
|
75
|
+
click.echo("Launching TUI...\n")
|
|
76
|
+
launch_tui(project_path)
|
|
77
|
+
except KeyboardInterrupt:
|
|
78
|
+
click.echo("\n\nShutting down...")
|
|
79
|
+
finally:
|
|
80
|
+
# Cleanup
|
|
81
|
+
if backend_process:
|
|
82
|
+
backend_process.terminate()
|
|
83
|
+
backend_process.wait()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_indexer(project_path: Path, debug: bool = False):
|
|
87
|
+
"""Run indexer and generate .srcodex/"""
|
|
88
|
+
# Create .srcodex/ directory structure
|
|
89
|
+
srcodex_dir = project_path / ".srcodex"
|
|
90
|
+
data_dir = srcodex_dir / "data"
|
|
91
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
# Database path
|
|
94
|
+
project_name = project_path.name
|
|
95
|
+
db_path = data_dir / f"{project_name}.db"
|
|
96
|
+
|
|
97
|
+
indexer = Indexer(str(db_path), verbose=debug)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
# Connect to database
|
|
101
|
+
indexer.connect_db()
|
|
102
|
+
|
|
103
|
+
# Track timing
|
|
104
|
+
start_time = time.time()
|
|
105
|
+
|
|
106
|
+
# Stage 1: Index symbols (always force-clear for new indexing)
|
|
107
|
+
click.echo("Stage 1: Extracting symbols with CTags...")
|
|
108
|
+
indexer.index_directory(str(project_path), extensions=['.c', '.h', '.cpp', '.cc', '.py'], force_clear=True)
|
|
109
|
+
|
|
110
|
+
# Stage 1.5: Field access analysis
|
|
111
|
+
click.echo("Stage 1.5: Analyzing field access patterns...")
|
|
112
|
+
field_analyzer = FieldAccessAnalyzer(indexer.conn, str(project_path))
|
|
113
|
+
field_analyzer.analyze_all_functions_parallel(clear_existing=True)
|
|
114
|
+
|
|
115
|
+
# Stage 2-4: Build cscope and resolve references
|
|
116
|
+
click.echo("Stage 2: Building cscope database...")
|
|
117
|
+
cscope_dir = data_dir / "cscope"
|
|
118
|
+
cscope_dir.mkdir(exist_ok=True)
|
|
119
|
+
indexer.build_cscope_database(str(cscope_dir))
|
|
120
|
+
|
|
121
|
+
click.echo("Stage 3: Ingesting references...")
|
|
122
|
+
ingestor = ReferenceIngestor(indexer.conn, str(project_path), cscope_dir)
|
|
123
|
+
ingestor.ingest_callees(clear_existing=True)
|
|
124
|
+
ingestor.ingest_callers(clear_existing=True)
|
|
125
|
+
ingestor.ingest_includes(clear_existing=True)
|
|
126
|
+
|
|
127
|
+
click.echo("Stage 4: Resolving semantic edges...")
|
|
128
|
+
|
|
129
|
+
resolver = ReferenceResolver(indexer.conn)
|
|
130
|
+
resolver.resolve_callees(clear_existing=True)
|
|
131
|
+
resolver.resolve_includes(clear_existing=True)
|
|
132
|
+
|
|
133
|
+
# Collect statistics
|
|
134
|
+
cursor = indexer.conn.cursor()
|
|
135
|
+
|
|
136
|
+
files_count = cursor.execute("SELECT COUNT(*) FROM files").fetchone()[0]
|
|
137
|
+
symbols_count = cursor.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
|
|
138
|
+
calls_count = cursor.execute("SELECT COUNT(*) FROM symbol_edges WHERE edge_type = 'CALLS'").fetchone()[0]
|
|
139
|
+
includes_count = cursor.execute("SELECT COUNT(*) FROM symbol_edges WHERE edge_type = 'INCLUDES'").fetchone()[0]
|
|
140
|
+
accesses_count = cursor.execute("SELECT COUNT(*) FROM symbol_edges WHERE edge_type = 'ACCESSES'").fetchone()[0]
|
|
141
|
+
avg_lines = 500 # Estimate: average C/Python file size
|
|
142
|
+
|
|
143
|
+
duration = time.time() - start_time
|
|
144
|
+
|
|
145
|
+
# Generate metadata.json
|
|
146
|
+
metadata = {
|
|
147
|
+
"project": {
|
|
148
|
+
"name": project_name,
|
|
149
|
+
"source_root": str(project_path),
|
|
150
|
+
"indexed_at": datetime.now().isoformat(),
|
|
151
|
+
"indexer_version": "0.1.0"
|
|
152
|
+
},
|
|
153
|
+
"stats": {
|
|
154
|
+
"files_indexed": files_count,
|
|
155
|
+
"total_symbols": symbols_count,
|
|
156
|
+
"avg_lines_per_file": int(avg_lines),
|
|
157
|
+
"edges": {
|
|
158
|
+
"calls": calls_count,
|
|
159
|
+
"includes": includes_count,
|
|
160
|
+
"accesses": accesses_count
|
|
161
|
+
},
|
|
162
|
+
"indexing_duration_seconds": round(duration, 2)
|
|
163
|
+
},
|
|
164
|
+
"paths": {
|
|
165
|
+
"source_root": ".",
|
|
166
|
+
"database": f".srcodex/data/{project_name}.db"
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
metadata_path = srcodex_dir / "metadata.json"
|
|
171
|
+
with open(metadata_path, 'w') as f:
|
|
172
|
+
json.dump(metadata, f, indent=2)
|
|
173
|
+
|
|
174
|
+
# Summary
|
|
175
|
+
click.echo(f"\n✓ Indexed {files_count:,} files, {symbols_count:,} symbols")
|
|
176
|
+
click.echo(f"✓ Edges: {calls_count:,} CALLS, {includes_count:,} INCLUDES, {accesses_count:,} ACCESSES")
|
|
177
|
+
click.echo(f"✓ Generated .srcodex/metadata.json")
|
|
178
|
+
click.echo(f"✓ Duration: {duration:.1f} seconds")
|
|
179
|
+
|
|
180
|
+
finally:
|
|
181
|
+
indexer.close_db()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def start_backend(project_path: Path, debug: bool = False):
|
|
185
|
+
"""Start FastAPI backend in background subprocess"""
|
|
186
|
+
|
|
187
|
+
# Set environment variable for project root
|
|
188
|
+
env = os.environ.copy()
|
|
189
|
+
env['SRCODEX_PROJECT_ROOT'] = str(project_path)
|
|
190
|
+
|
|
191
|
+
# Start uvicorn in background
|
|
192
|
+
stdout = None if debug else subprocess.DEVNULL
|
|
193
|
+
stderr = None if debug else subprocess.DEVNULL
|
|
194
|
+
|
|
195
|
+
proc = subprocess.Popen(
|
|
196
|
+
[sys.executable, '-m', 'uvicorn', 'srcodex.backend.chat:app', '--port', '8000'],
|
|
197
|
+
env=env,
|
|
198
|
+
stdout=stdout,
|
|
199
|
+
stderr=stderr,
|
|
200
|
+
cwd=str(project_path)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
time.sleep(2)
|
|
204
|
+
return proc
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def launch_tui(project_path: Path):
|
|
208
|
+
"""Launch Textual TUI"""
|
|
209
|
+
os.chdir(project_path)
|
|
210
|
+
app = SrcodexApp()
|
|
211
|
+
app.run()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def check_api_key() -> bool:
|
|
215
|
+
"""Check if any API key is configured"""
|
|
216
|
+
amd_key = os.getenv("AMD_LLM_API_KEY")
|
|
217
|
+
anthropic_key = os.getenv("ANTHROPIC_API_KEY")
|
|
218
|
+
|
|
219
|
+
return (amd_key and amd_key != "dummy") or bool(anthropic_key)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@click.command()
|
|
223
|
+
@click.argument('path', type=click.Path(exists=True))
|
|
224
|
+
def index(path):
|
|
225
|
+
"""Index a codebase without launching TUI"""
|
|
226
|
+
project_path = Path(path).resolve()
|
|
227
|
+
click.echo(f"Indexing {project_path}...")
|
|
228
|
+
run_indexer(project_path)
|
|
229
|
+
click.echo("Complete!")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@click.command()
|
|
233
|
+
def info():
|
|
234
|
+
"""Show project stats from .srcodex/metadata.json"""
|
|
235
|
+
try:
|
|
236
|
+
config = get_config()
|
|
237
|
+
stats = config.stats
|
|
238
|
+
|
|
239
|
+
click.echo(f"\nProject: {config.project_name}")
|
|
240
|
+
click.echo(f"Source root: {config.source_root}")
|
|
241
|
+
click.echo(f"Indexed at: {config.indexed_at}")
|
|
242
|
+
click.echo(f"\nStats:")
|
|
243
|
+
click.echo(f" Files: {stats['files_indexed']:,}")
|
|
244
|
+
click.echo(f" Symbols: {stats['total_symbols']:,}")
|
|
245
|
+
click.echo(f" Call edges: {stats['edges']['calls']:,}")
|
|
246
|
+
click.echo(f" Include edges: {stats['edges']['includes']:,}")
|
|
247
|
+
click.echo(f" Field access edges: {stats['edges']['accesses']:,}")
|
|
248
|
+
except FileNotFoundError as e:
|
|
249
|
+
click.echo(f"Error: {e}", err=True)
|
|
250
|
+
click.echo("No .srcodex/ found in current directory")
|
|
251
|
+
sys.exit(1)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == '__main__':
|
|
255
|
+
main()
|
srcodex/core/__init__.py
ADDED
|
File without changes
|
srcodex/core/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration Loader - Reads .srcodex/metadata.json
|
|
3
|
+
Provides project paths and stats to all services
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProjectConfig:
|
|
11
|
+
"""Loads and provides access to .srcodex/metadata.json"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, project_root: Path = None):
|
|
14
|
+
"""
|
|
15
|
+
Initialize config loader
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
project_root: Path to project root (defaults to searching upwards from cwd)
|
|
19
|
+
"""
|
|
20
|
+
if project_root is None:
|
|
21
|
+
# Search upwards from current directory for .srcodex/
|
|
22
|
+
project_root = self._find_project_root()
|
|
23
|
+
|
|
24
|
+
self.project_root = Path(project_root)
|
|
25
|
+
self.srcodex_dir = self.project_root / ".srcodex"
|
|
26
|
+
self.metadata_file = self.srcodex_dir / "metadata.json"
|
|
27
|
+
|
|
28
|
+
# Load metadata
|
|
29
|
+
if not self.metadata_file.exists():
|
|
30
|
+
raise FileNotFoundError(
|
|
31
|
+
f"No .srcodex/metadata.json found in {self.project_root}\n"
|
|
32
|
+
f"Run indexer first to generate .srcodex/ directory"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
with open(self.metadata_file, 'r') as f:
|
|
36
|
+
self.metadata = json.load(f)
|
|
37
|
+
|
|
38
|
+
def _find_project_root(self) -> Path:
|
|
39
|
+
"""
|
|
40
|
+
Search upwards from current directory for .srcodex/ directory
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Path to project root containing .srcodex/
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
FileNotFoundError if .srcodex/ not found in any parent directory
|
|
47
|
+
"""
|
|
48
|
+
current = Path.cwd()
|
|
49
|
+
|
|
50
|
+
# Search up to 10 levels (prevent infinite loop)
|
|
51
|
+
for _ in range(10):
|
|
52
|
+
srcodex_dir = current / ".srcodex"
|
|
53
|
+
if srcodex_dir.exists() and srcodex_dir.is_dir():
|
|
54
|
+
return current
|
|
55
|
+
|
|
56
|
+
# Move to parent
|
|
57
|
+
parent = current.parent
|
|
58
|
+
if parent == current:
|
|
59
|
+
# Reached filesystem root
|
|
60
|
+
break
|
|
61
|
+
current = parent
|
|
62
|
+
|
|
63
|
+
raise FileNotFoundError(
|
|
64
|
+
f"No .srcodex/ directory found in {Path.cwd()} or any parent directory.\n"
|
|
65
|
+
f"Run indexer first to generate .srcodex/ directory"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
with open(self.metadata_file, 'r') as f:
|
|
69
|
+
self.metadata = json.load(f)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def project_name(self) -> str:
|
|
73
|
+
"""Get project name"""
|
|
74
|
+
return self.metadata["project"]["name"]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def source_root(self) -> Path:
|
|
78
|
+
"""Get absolute path to source root"""
|
|
79
|
+
return self.project_root / self.metadata["paths"]["source_root"]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def database_path(self) -> Path:
|
|
83
|
+
"""Get absolute path to database"""
|
|
84
|
+
return self.project_root / self.metadata["paths"]["database"]
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def stats(self) -> Dict[str, Any]:
|
|
88
|
+
"""Get project statistics"""
|
|
89
|
+
return self.metadata["stats"]
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def indexed_at(self) -> str:
|
|
93
|
+
"""Get indexing timestamp"""
|
|
94
|
+
return self.metadata["project"]["indexed_at"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Global config instance (singleton pattern)
|
|
98
|
+
_config = None
|
|
99
|
+
|
|
100
|
+
def get_config(project_root: Path = None) -> ProjectConfig:
|
|
101
|
+
"""
|
|
102
|
+
Get global project configuration (singleton)
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
project_root: Path to project root (only used on first call)
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
ProjectConfig instance
|
|
109
|
+
"""
|
|
110
|
+
global _config
|
|
111
|
+
if _config is None:
|
|
112
|
+
_config = ProjectConfig(project_root)
|
|
113
|
+
return _config
|
srcodex/core/logger.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
# Create logs directory
|
|
5
|
+
LOG_DIR = Path(__file__).parent / "logs"
|
|
6
|
+
LOG_DIR.mkdir(exist_ok=True)
|
|
7
|
+
|
|
8
|
+
# Single log file (overwrites on each run)
|
|
9
|
+
log_file = LOG_DIR / "srcodex.log"
|
|
10
|
+
|
|
11
|
+
# Setup logger
|
|
12
|
+
logger = logging.getLogger("srcodex")
|
|
13
|
+
logger.setLevel(logging.DEBUG)
|
|
14
|
+
|
|
15
|
+
# File handler (mode='w' overwrites the file)
|
|
16
|
+
handler = logging.FileHandler(log_file, mode='w')
|
|
17
|
+
handler.setLevel(logging.DEBUG)
|
|
18
|
+
|
|
19
|
+
# Format
|
|
20
|
+
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
|
21
|
+
handler.setFormatter(formatter)
|
|
22
|
+
|
|
23
|
+
logger.addHandler(handler)
|
|
File without changes
|