codevira 1.6.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.
- codevira-1.6.0.dist-info/LICENSE +21 -0
- codevira-1.6.0.dist-info/METADATA +477 -0
- codevira-1.6.0.dist-info/RECORD +58 -0
- codevira-1.6.0.dist-info/WHEEL +5 -0
- codevira-1.6.0.dist-info/entry_points.txt +2 -0
- codevira-1.6.0.dist-info/top_level.txt +2 -0
- indexer/__init__.py +1 -0
- indexer/chunker.py +428 -0
- indexer/global_db.py +197 -0
- indexer/graph_generator.py +380 -0
- indexer/index_codebase.py +588 -0
- indexer/outcome_tracker.py +172 -0
- indexer/rule_learner.py +186 -0
- indexer/sqlite_graph.py +640 -0
- indexer/treesitter_parser.py +423 -0
- mcp_server/__init__.py +1 -0
- mcp_server/__main__.py +20 -0
- mcp_server/auto_init.py +257 -0
- mcp_server/cli.py +622 -0
- mcp_server/crash_logger.py +236 -0
- mcp_server/data/__init__.py +1 -0
- mcp_server/data/agents/builder.md +84 -0
- mcp_server/data/agents/developer.md +111 -0
- mcp_server/data/agents/documenter.md +138 -0
- mcp_server/data/agents/orchestrator.md +96 -0
- mcp_server/data/agents/planner.md +106 -0
- mcp_server/data/agents/reviewer.md +82 -0
- mcp_server/data/agents/tester.md +83 -0
- mcp_server/data/config.example.yaml +33 -0
- mcp_server/data/rules/coding-standards.md +48 -0
- mcp_server/data/rules/engineering-excellence.md +28 -0
- mcp_server/data/rules/git-cicd-governance.md +32 -0
- mcp_server/data/rules/git_commits.md +130 -0
- mcp_server/data/rules/incremental-updates.md +5 -0
- mcp_server/data/rules/master_rule.md +187 -0
- mcp_server/data/rules/multi-language.md +19 -0
- mcp_server/data/rules/persistence.md +21 -0
- mcp_server/data/rules/resilience-observability.md +17 -0
- mcp_server/data/rules/smoke-testing.md +48 -0
- mcp_server/data/rules/testing-standards.md +23 -0
- mcp_server/detect.py +284 -0
- mcp_server/gitignore.py +284 -0
- mcp_server/global_sync.py +187 -0
- mcp_server/http_server.py +341 -0
- mcp_server/ide_inject.py +444 -0
- mcp_server/launchd.py +156 -0
- mcp_server/migrate.py +215 -0
- mcp_server/paths.py +256 -0
- mcp_server/prompts.py +136 -0
- mcp_server/server.py +1049 -0
- mcp_server/tools/__init__.py +0 -0
- mcp_server/tools/changesets.py +223 -0
- mcp_server/tools/code_reader.py +335 -0
- mcp_server/tools/graph.py +637 -0
- mcp_server/tools/learning.py +238 -0
- mcp_server/tools/playbook.py +89 -0
- mcp_server/tools/roadmap.py +599 -0
- mcp_server/tools/search.py +145 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import hashlib
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from mcp_server.paths import get_data_dir, get_project_root
|
|
13
|
+
from indexer.sqlite_graph import SQLiteGraph
|
|
14
|
+
|
|
15
|
+
COLLECTION_NAME = "codebase_index"
|
|
16
|
+
|
|
17
|
+
# Global lock — prevents the background watcher and background full-index from
|
|
18
|
+
# writing to ChromaDB simultaneously. Both operations must acquire this lock
|
|
19
|
+
# before any ChromaDB write (add/delete/recreate collection).
|
|
20
|
+
_chroma_write_lock = threading.Lock()
|
|
21
|
+
|
|
22
|
+
# Atomic counters for background indexing progress
|
|
23
|
+
_bg_files_indexed: int = 0
|
|
24
|
+
_bg_total_files: int = 0
|
|
25
|
+
_bg_status: str = "idle" # idle | running | done | error
|
|
26
|
+
_bg_lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _project_root() -> Path:
|
|
30
|
+
return get_project_root()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _index_dir() -> Path:
|
|
34
|
+
# get_data_dir() is cached in paths.py (_data_dir_cache), so this is fast.
|
|
35
|
+
return get_data_dir() / "codeindex"
|
|
36
|
+
|
|
37
|
+
def _load_config() -> dict:
|
|
38
|
+
"""Load .codevira/config.yaml and return the 'project' sub-dict."""
|
|
39
|
+
config_path = get_data_dir() / "config.yaml"
|
|
40
|
+
if config_path.exists():
|
|
41
|
+
try:
|
|
42
|
+
import yaml
|
|
43
|
+
with open(config_path) as f:
|
|
44
|
+
raw = yaml.safe_load(f) or {}
|
|
45
|
+
# config.yaml nests settings under 'project' key
|
|
46
|
+
return raw.get("project", raw)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
def _check_search_deps() -> bool:
|
|
52
|
+
"""Return True if chromadb + sentence-transformers are available."""
|
|
53
|
+
try:
|
|
54
|
+
import chromadb # noqa: F401
|
|
55
|
+
import sentence_transformers # noqa: F401
|
|
56
|
+
return True
|
|
57
|
+
except ImportError:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def _get_chroma_client():
|
|
61
|
+
try:
|
|
62
|
+
import chromadb
|
|
63
|
+
except ImportError:
|
|
64
|
+
print("ERROR: semantic search requires chromadb.")
|
|
65
|
+
print(" Install it with: pip install 'codevira[search]'")
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
db_dir = str(_index_dir())
|
|
68
|
+
return chromadb.PersistentClient(path=db_dir)
|
|
69
|
+
|
|
70
|
+
def _get_embedding_fn():
|
|
71
|
+
try:
|
|
72
|
+
from chromadb.utils import embedding_functions
|
|
73
|
+
return embedding_functions.SentenceTransformerEmbeddingFunction(model_name="all-MiniLM-L6-v2")
|
|
74
|
+
except ImportError:
|
|
75
|
+
print("ERROR: semantic search requires sentence-transformers.")
|
|
76
|
+
print(" Install it with: pip install 'codevira[search]'")
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
def _compute_hash(file_path: Path) -> str:
|
|
80
|
+
hasher = hashlib.sha256()
|
|
81
|
+
with open(file_path, "rb") as f:
|
|
82
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
83
|
+
hasher.update(chunk)
|
|
84
|
+
return hasher.hexdigest()
|
|
85
|
+
|
|
86
|
+
def _get_changed_files(db: SQLiteGraph) -> list[tuple[str, str]]:
|
|
87
|
+
changed = []
|
|
88
|
+
seen_paths = set()
|
|
89
|
+
config = _load_config()
|
|
90
|
+
watched_dirs = config.get("watched_dirs", ["src"])
|
|
91
|
+
extensions = config.get("file_extensions", [".py", ".ts", ".tsx", ".go", ".rs"])
|
|
92
|
+
skip_dirs = config.get("skip_dirs", ["node_modules", ".venv", "__pycache__"])
|
|
93
|
+
|
|
94
|
+
for watch_dir in watched_dirs:
|
|
95
|
+
watch_path = _project_root() / watch_dir
|
|
96
|
+
if not watch_path.exists():
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
for p in watch_path.rglob("*"):
|
|
100
|
+
if not p.is_file():
|
|
101
|
+
continue
|
|
102
|
+
if p.suffix not in extensions:
|
|
103
|
+
continue
|
|
104
|
+
if any(skip in p.parts for skip in skip_dirs):
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
rel_path = str(p.relative_to(_project_root()))
|
|
109
|
+
if rel_path in seen_paths:
|
|
110
|
+
continue
|
|
111
|
+
seen_paths.add(rel_path)
|
|
112
|
+
current_hash = _compute_hash(p)
|
|
113
|
+
stored_hash = db.get_file_hash(rel_path)
|
|
114
|
+
|
|
115
|
+
if current_hash != stored_hash:
|
|
116
|
+
changed.append((rel_path, current_hash))
|
|
117
|
+
except Exception as e:
|
|
118
|
+
try:
|
|
119
|
+
from mcp_server.crash_logger import log_crash
|
|
120
|
+
log_crash(e, context="get_changed_files: hash check")
|
|
121
|
+
except Exception: pass
|
|
122
|
+
|
|
123
|
+
return changed
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _get_requested_files(file_paths: list[str]) -> list[tuple[str, str]]:
|
|
127
|
+
requested = []
|
|
128
|
+
seen_paths = set()
|
|
129
|
+
config = _load_config()
|
|
130
|
+
extensions = config.get("file_extensions", [".py", ".ts", ".tsx", ".go", ".rs"])
|
|
131
|
+
skip_dirs = config.get("skip_dirs", ["node_modules", ".venv", "__pycache__"])
|
|
132
|
+
|
|
133
|
+
for raw_path in file_paths:
|
|
134
|
+
candidate = Path(raw_path)
|
|
135
|
+
abs_path = candidate if candidate.is_absolute() else _project_root() / candidate
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
rel_path = str(abs_path.resolve().relative_to(_project_root()))
|
|
139
|
+
except ValueError:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if rel_path in seen_paths:
|
|
143
|
+
continue
|
|
144
|
+
if not abs_path.exists() or not abs_path.is_file():
|
|
145
|
+
continue
|
|
146
|
+
if abs_path.suffix not in extensions:
|
|
147
|
+
continue
|
|
148
|
+
if any(skip in abs_path.parts for skip in skip_dirs):
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
seen_paths.add(rel_path)
|
|
152
|
+
requested.append((rel_path, _compute_hash(abs_path)))
|
|
153
|
+
|
|
154
|
+
return requested
|
|
155
|
+
|
|
156
|
+
def _chunk_to_document(chunk) -> tuple[str, str, dict]:
|
|
157
|
+
doc_id = f"{chunk.file_path}::{chunk.chunk_type}::{chunk.name}::{chunk.start_line}"
|
|
158
|
+
document = f"{chunk.file_path} — {chunk.name}\n{chunk.docstring}\n\n{chunk.source_text}"
|
|
159
|
+
metadata = {
|
|
160
|
+
"file_path": chunk.file_path,
|
|
161
|
+
"name": chunk.name,
|
|
162
|
+
"chunk_type": chunk.chunk_type,
|
|
163
|
+
"start_line": chunk.start_line,
|
|
164
|
+
"end_line": chunk.end_line,
|
|
165
|
+
"layer": chunk.layer,
|
|
166
|
+
}
|
|
167
|
+
return doc_id, document, metadata
|
|
168
|
+
|
|
169
|
+
def cmd_full_rebuild():
|
|
170
|
+
from indexer.chunker import chunk_project
|
|
171
|
+
from indexer.graph_generator import generate_graph_sqlite
|
|
172
|
+
from rich.console import Console
|
|
173
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
|
174
|
+
|
|
175
|
+
console = Console()
|
|
176
|
+
_index_dir().mkdir(parents=True, exist_ok=True)
|
|
177
|
+
db = SQLiteGraph(get_data_dir() / "graph" / "graph.db")
|
|
178
|
+
|
|
179
|
+
if not _check_search_deps():
|
|
180
|
+
console.print("[yellow]⚠[/yellow] Semantic search skipped — install with: [bold]pip install 'codevira\\[search\\]'[/bold]")
|
|
181
|
+
# Still build the graph even without search deps
|
|
182
|
+
from indexer.graph_generator import generate_graph_sqlite
|
|
183
|
+
result = generate_graph_sqlite(str(_project_root()), str(get_data_dir() / "graph" / "graph.db"))
|
|
184
|
+
console.print(f"[green]✓[/green] Graph built: {result.get('nodes_added', 0)} nodes, {result.get('edges_added', 0)} edges.")
|
|
185
|
+
db.close()
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
client = _get_chroma_client()
|
|
189
|
+
try:
|
|
190
|
+
client.delete_collection(COLLECTION_NAME)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
embed_fn = _get_embedding_fn()
|
|
195
|
+
collection = client.create_collection(name=COLLECTION_NAME, embedding_function=embed_fn)
|
|
196
|
+
|
|
197
|
+
config = _load_config()
|
|
198
|
+
watched_dirs = config.get("watched_dirs", ["src"])
|
|
199
|
+
extensions = config.get("file_extensions", [".py", ".ts", ".tsx", ".go", ".rs"])
|
|
200
|
+
skip_dirs = config.get("skip_dirs", ["node_modules", ".venv", "__pycache__"])
|
|
201
|
+
|
|
202
|
+
all_chunks = []
|
|
203
|
+
file_hashes = {}
|
|
204
|
+
seen_chunk_ids = set()
|
|
205
|
+
|
|
206
|
+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TaskProgressColumn(), console=console) as progress:
|
|
207
|
+
task1 = progress.add_task("[cyan]Parsing and chunking source files...", total=None)
|
|
208
|
+
|
|
209
|
+
# Chunk the entire project once (not per watched_dir)
|
|
210
|
+
all_project_chunks = chunk_project(str(_project_root()))
|
|
211
|
+
|
|
212
|
+
for watch_dir in watched_dirs:
|
|
213
|
+
abs_dir = _project_root() / watch_dir
|
|
214
|
+
if not abs_dir.exists():
|
|
215
|
+
continue
|
|
216
|
+
wd_str = str(watch_dir)
|
|
217
|
+
for c in all_project_chunks:
|
|
218
|
+
if c.file_path.startswith(wd_str) or wd_str == ".":
|
|
219
|
+
chunk_id = f"{c.file_path}::{c.chunk_type}::{c.name}::{c.start_line}"
|
|
220
|
+
if chunk_id not in seen_chunk_ids:
|
|
221
|
+
seen_chunk_ids.add(chunk_id)
|
|
222
|
+
all_chunks.append(c)
|
|
223
|
+
|
|
224
|
+
for p in abs_dir.rglob("*"):
|
|
225
|
+
if p.is_file() and p.suffix in extensions and not any(s in p.parts for s in skip_dirs):
|
|
226
|
+
rel = str(p.relative_to(_project_root()))
|
|
227
|
+
file_hashes[rel] = _compute_hash(p)
|
|
228
|
+
|
|
229
|
+
progress.update(task1, completed=100)
|
|
230
|
+
task2 = progress.add_task(f"[cyan]Embedding {len(all_chunks)} chunks into ChromaDB...", total=len(all_chunks))
|
|
231
|
+
|
|
232
|
+
ids, docs, metadatas = [], [], []
|
|
233
|
+
for i, chunk in enumerate(all_chunks):
|
|
234
|
+
doc_id, doc, meta = _chunk_to_document(chunk)
|
|
235
|
+
ids.append(doc_id)
|
|
236
|
+
docs.append(doc)
|
|
237
|
+
metadatas.append(meta)
|
|
238
|
+
|
|
239
|
+
if len(ids) >= 100 or i == len(all_chunks) - 1:
|
|
240
|
+
if ids:
|
|
241
|
+
collection.add(ids=ids, documents=docs, metadatas=metadatas)
|
|
242
|
+
ids, docs, metadatas = [], [], []
|
|
243
|
+
progress.update(task2, advance=1)
|
|
244
|
+
|
|
245
|
+
console.print(f"[green]✓[/green] Full rebuild complete: {len(all_chunks)} chunks indexed.")
|
|
246
|
+
|
|
247
|
+
for path, f_hash in file_hashes.items():
|
|
248
|
+
db.update_file_hash(path, f_hash)
|
|
249
|
+
|
|
250
|
+
console.print(f"[cyan]Generating auto-graph stubs...[/cyan]")
|
|
251
|
+
generate_graph_sqlite(str(_project_root()), str(db.db_path))
|
|
252
|
+
db.close()
|
|
253
|
+
|
|
254
|
+
def cmd_incremental(quiet: bool = False, file_paths: list[str] | None = None):
|
|
255
|
+
from indexer.chunker import chunk_file
|
|
256
|
+
from indexer.graph_generator import generate_graph_sqlite
|
|
257
|
+
from rich.console import Console
|
|
258
|
+
console = Console(quiet=quiet)
|
|
259
|
+
|
|
260
|
+
db = SQLiteGraph(get_data_dir() / "graph" / "graph.db")
|
|
261
|
+
explicit_files = file_paths or []
|
|
262
|
+
changed_items = _get_requested_files(explicit_files) if explicit_files else _get_changed_files(db)
|
|
263
|
+
|
|
264
|
+
if not changed_items:
|
|
265
|
+
if explicit_files:
|
|
266
|
+
console.print("[green]✓[/green] No matching files found to re-index.")
|
|
267
|
+
else:
|
|
268
|
+
console.print("[green]✓[/green] No files changed. Index is up to date.")
|
|
269
|
+
db.close()
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
file_label = "requested file(s)" if explicit_files else "changed file(s)"
|
|
273
|
+
console.print(f"[bold cyan]Incremental update:[/bold cyan] {len(changed_items)} {file_label}")
|
|
274
|
+
|
|
275
|
+
client = _get_chroma_client()
|
|
276
|
+
embed_fn = _get_embedding_fn()
|
|
277
|
+
try:
|
|
278
|
+
collection = client.get_collection(COLLECTION_NAME, embedding_function=embed_fn)
|
|
279
|
+
except Exception:
|
|
280
|
+
console.print("[red]No existing index found.[/red] Run codevira index --full first.")
|
|
281
|
+
db.close()
|
|
282
|
+
sys.exit(1)
|
|
283
|
+
|
|
284
|
+
indexed_any = False
|
|
285
|
+
# Acquire ChromaDB write lock to prevent concurrent writes with the
|
|
286
|
+
# background watcher or background full-index thread.
|
|
287
|
+
with _chroma_write_lock:
|
|
288
|
+
for fpath, fhash in changed_items:
|
|
289
|
+
try:
|
|
290
|
+
collection.delete(where={"file_path": fpath})
|
|
291
|
+
except Exception as e:
|
|
292
|
+
try:
|
|
293
|
+
from mcp_server.crash_logger import log_crash
|
|
294
|
+
log_crash(e, context=f"incremental index: delete old chunks for {fpath}")
|
|
295
|
+
except Exception: pass
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
chunks = chunk_file(str(_project_root() / fpath), str(_project_root()))
|
|
299
|
+
if chunks:
|
|
300
|
+
ids, docs, metas = [], [], []
|
|
301
|
+
for chunk in chunks:
|
|
302
|
+
doc_id, doc, meta = _chunk_to_document(chunk)
|
|
303
|
+
ids.append(doc_id)
|
|
304
|
+
docs.append(doc)
|
|
305
|
+
metas.append(meta)
|
|
306
|
+
collection.add(ids=ids, documents=docs, metadatas=metas)
|
|
307
|
+
|
|
308
|
+
db.update_file_hash(fpath, fhash)
|
|
309
|
+
indexed_any = True
|
|
310
|
+
console.print(f" [green]+[/green] Re-indexed {len(chunks)} chunks for {fpath}")
|
|
311
|
+
|
|
312
|
+
except Exception as e:
|
|
313
|
+
console.print(f"[red]Error indexing {fpath}: {e}[/red]")
|
|
314
|
+
try:
|
|
315
|
+
from mcp_server.crash_logger import log_crash
|
|
316
|
+
log_crash(e, context=f"incremental index: indexing {fpath}")
|
|
317
|
+
except Exception: pass
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
if indexed_any:
|
|
321
|
+
generate_graph_sqlite(str(_project_root()), str(db.db_path))
|
|
322
|
+
|
|
323
|
+
db.close()
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
_watcher_logger = logging.getLogger("codevira.watcher")
|
|
327
|
+
|
|
328
|
+
# Debounce delay: how long to wait after the last file change before reindexing.
|
|
329
|
+
# This prevents rapid saves (auto-formatters, IDE auto-save) from triggering
|
|
330
|
+
# dozens of reindex cycles.
|
|
331
|
+
DEBOUNCE_SECONDS = 2.0
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def start_background_watcher(quiet: bool = True):
|
|
335
|
+
"""
|
|
336
|
+
Start a non-blocking file watcher that auto-reindexes on source changes.
|
|
337
|
+
|
|
338
|
+
Returns the watchdog Observer (already started) so the caller can stop it
|
|
339
|
+
later if needed. The watcher uses a debounce timer: after the last file
|
|
340
|
+
event, it waits DEBOUNCE_SECONDS before running cmd_incremental().
|
|
341
|
+
|
|
342
|
+
Called automatically by the MCP server on startup.
|
|
343
|
+
"""
|
|
344
|
+
from watchdog.observers import Observer
|
|
345
|
+
from watchdog.events import FileSystemEventHandler
|
|
346
|
+
|
|
347
|
+
config = _load_config()
|
|
348
|
+
watched_dirs = config.get("watched_dirs", ["src"])
|
|
349
|
+
extensions = config.get("file_extensions", [".py", ".ts", ".tsx", ".go", ".rs"])
|
|
350
|
+
skip_dirs = config.get("skip_dirs", ["node_modules", ".venv", "__pycache__"])
|
|
351
|
+
|
|
352
|
+
class DebouncedHandler(FileSystemEventHandler):
|
|
353
|
+
def __init__(self):
|
|
354
|
+
super().__init__()
|
|
355
|
+
self._timer: threading.Timer | None = None
|
|
356
|
+
self._lock = threading.Lock()
|
|
357
|
+
|
|
358
|
+
def _schedule_reindex(self, src_path: str):
|
|
359
|
+
abs_path = Path(src_path)
|
|
360
|
+
if not any(abs_path.suffix == ext for ext in extensions):
|
|
361
|
+
return
|
|
362
|
+
if any(skip in abs_path.parts for skip in skip_dirs):
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
with self._lock:
|
|
366
|
+
# Cancel any pending timer and restart the debounce window
|
|
367
|
+
if self._timer is not None:
|
|
368
|
+
self._timer.cancel()
|
|
369
|
+
self._timer = threading.Timer(DEBOUNCE_SECONDS, self._do_reindex)
|
|
370
|
+
self._timer.daemon = True
|
|
371
|
+
self._timer.start()
|
|
372
|
+
|
|
373
|
+
def _do_reindex(self):
|
|
374
|
+
try:
|
|
375
|
+
_watcher_logger.debug("File change detected — running incremental reindex")
|
|
376
|
+
# Note: cmd_incremental acquires _chroma_write_lock internally,
|
|
377
|
+
# so we don't need to acquire it here.
|
|
378
|
+
cmd_incremental(quiet=quiet)
|
|
379
|
+
_watcher_logger.debug("Incremental reindex complete")
|
|
380
|
+
except Exception as e:
|
|
381
|
+
_watcher_logger.warning("Background reindex failed: %s", e)
|
|
382
|
+
try:
|
|
383
|
+
from mcp_server.crash_logger import log_crash
|
|
384
|
+
log_crash(e, context="background watcher: incremental reindex")
|
|
385
|
+
except Exception: pass
|
|
386
|
+
|
|
387
|
+
def on_modified(self, event):
|
|
388
|
+
if not event.is_directory:
|
|
389
|
+
self._schedule_reindex(event.src_path)
|
|
390
|
+
|
|
391
|
+
def on_created(self, event):
|
|
392
|
+
if not event.is_directory:
|
|
393
|
+
self._schedule_reindex(event.src_path)
|
|
394
|
+
|
|
395
|
+
def on_deleted(self, event):
|
|
396
|
+
if not event.is_directory:
|
|
397
|
+
self._schedule_reindex(event.src_path)
|
|
398
|
+
|
|
399
|
+
observer = Observer()
|
|
400
|
+
observer.daemon = True
|
|
401
|
+
handler = DebouncedHandler()
|
|
402
|
+
|
|
403
|
+
scheduled = 0
|
|
404
|
+
for wd in watched_dirs:
|
|
405
|
+
path = _project_root() / wd
|
|
406
|
+
if path.exists():
|
|
407
|
+
observer.schedule(handler, str(path), recursive=True)
|
|
408
|
+
scheduled += 1
|
|
409
|
+
|
|
410
|
+
if scheduled > 0:
|
|
411
|
+
observer.start()
|
|
412
|
+
_watcher_logger.info(
|
|
413
|
+
"Background watcher started — monitoring %d dir(s): %s",
|
|
414
|
+
scheduled, ", ".join(watched_dirs),
|
|
415
|
+
)
|
|
416
|
+
else:
|
|
417
|
+
_watcher_logger.warning("No valid watched_dirs found — watcher not started")
|
|
418
|
+
|
|
419
|
+
return observer
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def cmd_watch():
|
|
423
|
+
"""Blocking CLI mode: start watcher and keep the process alive."""
|
|
424
|
+
config = _load_config()
|
|
425
|
+
watched_dirs = config.get("watched_dirs", ["src"])
|
|
426
|
+
print(f"Watching for changes in: {', '.join(watched_dirs)}...")
|
|
427
|
+
print("Press Ctrl+C to stop.\n")
|
|
428
|
+
|
|
429
|
+
observer = start_background_watcher(quiet=False)
|
|
430
|
+
try:
|
|
431
|
+
while True:
|
|
432
|
+
time.sleep(1)
|
|
433
|
+
except KeyboardInterrupt:
|
|
434
|
+
observer.stop()
|
|
435
|
+
observer.join()
|
|
436
|
+
|
|
437
|
+
def get_indexing_status() -> dict:
|
|
438
|
+
"""Return the current background indexing progress. Thread-safe."""
|
|
439
|
+
with _bg_lock:
|
|
440
|
+
return {
|
|
441
|
+
"status": _bg_status,
|
|
442
|
+
"files_indexed": _bg_files_indexed,
|
|
443
|
+
"total_files": _bg_total_files,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def start_background_full_index(callback=None) -> "threading.Thread":
|
|
448
|
+
"""Start a full index rebuild in a background daemon thread.
|
|
449
|
+
|
|
450
|
+
This is used by auto_init.py to build the index without blocking tool calls.
|
|
451
|
+
The ChromaDB write lock (_chroma_write_lock) prevents concurrent writes
|
|
452
|
+
with the file watcher.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
callback: Optional callable invoked when indexing completes.
|
|
456
|
+
Called with (status: str) where status is 'done' or 'error'.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
The started Thread object.
|
|
460
|
+
"""
|
|
461
|
+
global _bg_status, _bg_files_indexed, _bg_total_files
|
|
462
|
+
|
|
463
|
+
def _run():
|
|
464
|
+
global _bg_status, _bg_files_indexed, _bg_total_files
|
|
465
|
+
with _bg_lock:
|
|
466
|
+
_bg_status = "running"
|
|
467
|
+
_bg_files_indexed = 0
|
|
468
|
+
_bg_total_files = 0
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
with _chroma_write_lock:
|
|
472
|
+
cmd_full_rebuild()
|
|
473
|
+
with _bg_lock:
|
|
474
|
+
_bg_status = "done"
|
|
475
|
+
except Exception as e:
|
|
476
|
+
with _bg_lock:
|
|
477
|
+
_bg_status = "error"
|
|
478
|
+
_watcher_logger.error("Background full-index failed: %s", e)
|
|
479
|
+
try:
|
|
480
|
+
from mcp_server.crash_logger import log_crash
|
|
481
|
+
log_crash(e, context="background full-index")
|
|
482
|
+
except Exception:
|
|
483
|
+
pass
|
|
484
|
+
finally:
|
|
485
|
+
if callback is not None:
|
|
486
|
+
try:
|
|
487
|
+
callback(_bg_status)
|
|
488
|
+
except Exception:
|
|
489
|
+
pass
|
|
490
|
+
|
|
491
|
+
t = threading.Thread(target=_run, daemon=True, name="codevira-bg-index")
|
|
492
|
+
t.start()
|
|
493
|
+
return t
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def cmd_status():
|
|
497
|
+
from rich.console import Console
|
|
498
|
+
from rich.table import Table
|
|
499
|
+
from rich.panel import Panel
|
|
500
|
+
|
|
501
|
+
console = Console()
|
|
502
|
+
db = SQLiteGraph(get_data_dir() / "graph" / "graph.db")
|
|
503
|
+
|
|
504
|
+
search_available = True
|
|
505
|
+
try:
|
|
506
|
+
client = _get_chroma_client()
|
|
507
|
+
embed_fn = _get_embedding_fn()
|
|
508
|
+
collection = client.get_collection(COLLECTION_NAME, embedding_function=embed_fn)
|
|
509
|
+
chunk_count = collection.count()
|
|
510
|
+
except ImportError:
|
|
511
|
+
chunk_count = 0
|
|
512
|
+
search_available = False
|
|
513
|
+
except Exception as e:
|
|
514
|
+
chunk_count = 0
|
|
515
|
+
console.print(f"[yellow]Warning: could not read search index: {e}[/yellow]")
|
|
516
|
+
|
|
517
|
+
stale_files = _get_changed_files(db)
|
|
518
|
+
|
|
519
|
+
table = Table(show_header=False, box=None)
|
|
520
|
+
table.add_row("[cyan]ChromaDB Chunks:[/cyan]", str(chunk_count))
|
|
521
|
+
table.add_row("[cyan]Outdated Files:[/cyan]", str(len(stale_files)))
|
|
522
|
+
|
|
523
|
+
panel = Panel(
|
|
524
|
+
table,
|
|
525
|
+
title="[bold green]Codevira Index Status[/bold green]",
|
|
526
|
+
expand=False,
|
|
527
|
+
border_style="green"
|
|
528
|
+
)
|
|
529
|
+
console.print(panel)
|
|
530
|
+
|
|
531
|
+
if stale_files:
|
|
532
|
+
console.print("\n[yellow]Files requiring re-indexing:[/yellow]")
|
|
533
|
+
for fp, _ in stale_files[:10]:
|
|
534
|
+
console.print(f" - {fp}")
|
|
535
|
+
if len(stale_files) > 10:
|
|
536
|
+
console.print(f" ... and {len(stale_files) - 10} more.")
|
|
537
|
+
|
|
538
|
+
db.close()
|
|
539
|
+
|
|
540
|
+
def cmd_generate_graph():
|
|
541
|
+
from indexer.graph_generator import generate_graph_sqlite
|
|
542
|
+
db_path = str(get_data_dir() / "graph" / "graph.db")
|
|
543
|
+
print(f"Generating context graph nodes from {_project_root()} into SQLite")
|
|
544
|
+
result = generate_graph_sqlite(str(_project_root()), db_path)
|
|
545
|
+
|
|
546
|
+
print(f" Files scanned: {result['files_processed']}")
|
|
547
|
+
print(f" Nodes added: {result['nodes_added']}")
|
|
548
|
+
print(f" Nodes skipped: {result['nodes_skipped']}")
|
|
549
|
+
|
|
550
|
+
def cmd_bootstrap_roadmap():
|
|
551
|
+
from indexer.graph_generator import generate_roadmap_stub
|
|
552
|
+
roadmap_file = get_data_dir() / "roadmap.yaml"
|
|
553
|
+
if roadmap_file.exists():
|
|
554
|
+
print(f"Roadmap already exists at {roadmap_file}")
|
|
555
|
+
return
|
|
556
|
+
generate_roadmap_stub(str(_project_root()), str(roadmap_file))
|
|
557
|
+
|
|
558
|
+
if __name__ == "__main__":
|
|
559
|
+
parser = argparse.ArgumentParser(description="Codevira Codebase Indexer (SQLite + ChromaDB + SHA256)")
|
|
560
|
+
parser.add_argument("--full", action="store_true", help="Perform a full rebuild of the index.")
|
|
561
|
+
parser.add_argument("--status", action="store_true", help="Show index status and outdated files.")
|
|
562
|
+
parser.add_argument("--watch", action="store_true", help="Watch for file changes and update incrementally.")
|
|
563
|
+
parser.add_argument("--generate-graph", action="store_true", help="Auto-generate SQLite graph stubs.")
|
|
564
|
+
parser.add_argument("--bootstrap-roadmap", action="store_true", help="Create initial roadmap.yaml stub.")
|
|
565
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="Suppress non-error output.")
|
|
566
|
+
args = parser.parse_args()
|
|
567
|
+
|
|
568
|
+
if args.status:
|
|
569
|
+
cmd_status()
|
|
570
|
+
elif args.full:
|
|
571
|
+
cmd_full_rebuild()
|
|
572
|
+
if args.generate_graph:
|
|
573
|
+
print()
|
|
574
|
+
cmd_generate_graph()
|
|
575
|
+
if args.bootstrap_roadmap:
|
|
576
|
+
print()
|
|
577
|
+
cmd_bootstrap_roadmap()
|
|
578
|
+
elif args.watch:
|
|
579
|
+
cmd_watch()
|
|
580
|
+
elif args.generate_graph:
|
|
581
|
+
cmd_generate_graph()
|
|
582
|
+
if args.bootstrap_roadmap:
|
|
583
|
+
print()
|
|
584
|
+
cmd_bootstrap_roadmap()
|
|
585
|
+
elif args.bootstrap_roadmap:
|
|
586
|
+
cmd_bootstrap_roadmap()
|
|
587
|
+
else:
|
|
588
|
+
cmd_incremental(quiet=args.quiet)
|