vortexa 0.3.3__tar.gz → 0.3.4__tar.gz

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 (37) hide show
  1. {vortexa-0.3.3 → vortexa-0.3.4}/PKG-INFO +1 -1
  2. {vortexa-0.3.3 → vortexa-0.3.4}/pyproject.toml +1 -1
  3. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/graph.py +19 -0
  4. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/indexer.py +281 -0
  5. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/types.py +9 -0
  6. vortexa-0.3.4/src/vortexa/interfaces/cli.py +670 -0
  7. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa.egg-info/PKG-INFO +1 -1
  8. vortexa-0.3.3/src/vortexa/interfaces/cli.py +0 -307
  9. {vortexa-0.3.3 → vortexa-0.3.4}/LICENSE +0 -0
  10. {vortexa-0.3.3 → vortexa-0.3.4}/README.md +0 -0
  11. {vortexa-0.3.3 → vortexa-0.3.4}/setup.cfg +0 -0
  12. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/__init__.py +0 -0
  13. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/__init__.py +0 -0
  14. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/chunking.py +0 -0
  15. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/context_engine.py +0 -0
  16. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/embedding.py +0 -0
  17. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/inference.py +0 -0
  18. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/language.py +0 -0
  19. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/lf4_v4_model.py +0 -0
  20. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/v4_embedder.py +0 -0
  21. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/core/vortex_score.py +0 -0
  22. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/interfaces/__init__.py +0 -0
  23. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/interfaces/mcp_server.py +0 -0
  24. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/interfaces/watcher.py +0 -0
  25. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/search/__init__.py +0 -0
  26. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/search/ranking.py +0 -0
  27. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/search/search.py +0 -0
  28. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/search/tokens.py +0 -0
  29. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/storage/__init__.py +0 -0
  30. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/storage/bm25.py +0 -0
  31. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/storage/vector_store.py +0 -0
  32. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa/storage/walker.py +0 -0
  33. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa.egg-info/SOURCES.txt +0 -0
  34. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa.egg-info/dependency_links.txt +0 -0
  35. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa.egg-info/entry_points.txt +0 -0
  36. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa.egg-info/requires.txt +0 -0
  37. {vortexa-0.3.3 → vortexa-0.3.4}/src/vortexa.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vortexa
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Codebase indexing and semantic search engine
5
5
  Author-email: VortexAI <koulabhay25@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vortexa"
7
- version = "0.3.3"
7
+ version = "0.3.4"
8
8
  description = "Codebase indexing and semantic search engine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -92,6 +92,24 @@ class RepoGraph:
92
92
  return [e.dst if direction == "out" else e.src for e in edges if e.kind == kind]
93
93
  return [e.dst if direction == "out" else e.src for e in edges]
94
94
 
95
+ def edges_from(self, node_id: str, kind: Optional[str] = None) -> List[GraphEdge]:
96
+ return [e for e in self._out.get(node_id, []) if kind is None or e.kind == kind]
97
+
98
+ def edges_to(self, node_id: str, kind: Optional[str] = None) -> List[GraphEdge]:
99
+ return [e for e in self._in.get(node_id, []) if kind is None or e.kind == kind]
100
+
101
+ def find_file_node(self, file_path: str) -> Optional[GraphNode]:
102
+ for node in self.nodes.values():
103
+ if node.kind == "file" and node.path == file_path:
104
+ return node
105
+ return None
106
+
107
+ def find_nodes_in_file(self, file_path: str) -> List[GraphNode]:
108
+ return [self.nodes[nid] for nid in self._file_symbols.get(file_path, set()) if nid in self.nodes]
109
+
110
+ def find_nodes_by_name(self, name: str) -> List[GraphNode]:
111
+ return [self.nodes[nid] for nid in self._name_index.get(name, set()) if nid in self.nodes]
112
+
95
113
  def expand(self, seed_ids: List[str], max_hops: int = 2, max_size: int = 100) -> List[Tuple[str, int]]:
96
114
  """BFS from seed nodes, return (node_id, hop_count) pairs."""
97
115
  visited: Set[str] = set()
@@ -238,6 +256,7 @@ class RepoGraphBuilder:
238
256
  candidates = self.graph.resolve_name(name)
239
257
  for cid in candidates[:3]: # limit candidates
240
258
  self.graph.add_edge(file_id, cid, "REFERENCES", weight=0.3)
259
+ self.graph.add_edge(file_id, cid, "CALLS", weight=0.3)
241
260
 
242
261
  def build(self, files: Dict[str, str]) -> RepoGraph:
243
262
  """Build the graph from a {path: content} dict."""
@@ -20,14 +20,17 @@ from typing import cast
20
20
 
21
21
  import lmdb
22
22
  import numpy as np
23
+
23
24
  from vortexa.core.chunking import chunk_source
24
25
  from vortexa.core.embedding import Embedder
26
+ from vortexa.core.graph import RepoGraph
25
27
  from vortexa.core.language import detect_language, get_extensions
26
28
  from vortexa.core.types import (
27
29
  Chunk,
28
30
  ChunkConfig,
29
31
  Encoder,
30
32
  IndexStats,
33
+ SearchMode,
31
34
  SearchResult,
32
35
  )
33
36
  from vortexa.search.search import search as _search
@@ -120,6 +123,7 @@ class CodebaseIndexer:
120
123
  self.chunk_memo: dict[str, str] = {} # chunk_id -> chunk_hash (for memoization)
121
124
  self._vector_store: VectorStore | None = None
122
125
  self._bm25_index: BM25Index | None = None
126
+ self._repo_graph: RepoGraph | None = None
123
127
 
124
128
  # Stats
125
129
  self._memo_hits = 0
@@ -303,6 +307,283 @@ class CodebaseIndexer:
303
307
  alpha=alpha,
304
308
  )
305
309
 
310
+ # ── Context resolution ──────────────────────────────────────────────
311
+
312
+ def _build_repo_graph(self) -> RepoGraph:
313
+ """Build a repo graph from indexed Python files."""
314
+ from vortexa.core.graph import RepoGraphBuilder
315
+ builder = RepoGraphBuilder()
316
+ files: dict[str, str] = {}
317
+ for rel in self.file_hashes:
318
+ file_path = self.root / rel
319
+ if file_path.suffix == ".py":
320
+ try:
321
+ files[rel] = file_path.read_text(encoding="utf-8", errors="replace")
322
+ except OSError:
323
+ pass
324
+ return builder.build(files)
325
+
326
+ def _find_test_files(self, primary_files: set[str]) -> list[str]:
327
+ test_files: list[str] = []
328
+ for file_path in primary_files:
329
+ path = Path(file_path)
330
+ stem = path.stem
331
+ suffix = path.suffix
332
+ parent = path.parent
333
+ candidates = [
334
+ parent / f"test_{stem}{suffix}",
335
+ parent / f"{stem}_test{suffix}",
336
+ ]
337
+ for candidate in candidates:
338
+ candidate_str = str(candidate)
339
+ if candidate_str != file_path and candidate_str not in test_files:
340
+ if candidate_str in self.file_hashes:
341
+ test_files.append(candidate_str)
342
+ return test_files
343
+
344
+ def _find_imports_importers(
345
+ self, primary_files: set[str], graph: RepoGraph
346
+ ) -> tuple[list[str], list[str]]:
347
+ imports: list[str] = []
348
+ imported_by: list[str] = []
349
+ for file_path in primary_files:
350
+ file_node = graph.find_file_node(file_path)
351
+ if file_node is None:
352
+ continue
353
+ for edge in graph.edges_from(file_node.id, kind="IMPORTS"):
354
+ target = graph.nodes.get(edge.dst)
355
+ if target and target.path and target.path not in primary_files:
356
+ if target.path not in imports:
357
+ imports.append(target.path)
358
+ for edge in graph.edges_from(file_node.id, kind="IMPORTS_FROM"):
359
+ target = graph.nodes.get(edge.dst)
360
+ if target and target.path and target.path not in primary_files:
361
+ if target.path not in imports:
362
+ imports.append(target.path)
363
+ for edge in graph.edges_to(file_node.id, kind="IMPORTS"):
364
+ source = graph.nodes.get(edge.src)
365
+ if source and source.path and source.path not in primary_files:
366
+ if source.path not in imported_by:
367
+ imported_by.append(source.path)
368
+ for edge in graph.edges_to(file_node.id, kind="IMPORTS_FROM"):
369
+ source = graph.nodes.get(edge.src)
370
+ if source and source.path and source.path not in primary_files:
371
+ if source.path not in imported_by:
372
+ imported_by.append(source.path)
373
+ return imports, imported_by
374
+
375
+ def _find_symbols(self, primary_files: set[str], graph: RepoGraph) -> list[dict]:
376
+ symbols: list[dict] = []
377
+ seen: set[str] = set()
378
+ skip_kinds = {"file"}
379
+ for file_path in primary_files:
380
+ for node_id in graph._file_symbols.get(file_path, set()):
381
+ if node_id in seen:
382
+ continue
383
+ seen.add(node_id)
384
+ node = graph.nodes.get(node_id)
385
+ if not node or node.kind in skip_kinds:
386
+ continue
387
+ symbols.append({
388
+ "name": node.name,
389
+ "kind": node.kind,
390
+ "file": node.path,
391
+ "line": node.line,
392
+ })
393
+ return symbols[:15]
394
+
395
+ def _find_callers_callees(
396
+ self, primary_files: set[str], graph: RepoGraph
397
+ ) -> tuple[list[dict], list[dict]]:
398
+ callers: list[dict] = []
399
+ callees: list[dict] = []
400
+ for file_path in primary_files:
401
+ for node_id in graph._file_symbols.get(file_path, set()):
402
+ node = graph.nodes.get(node_id)
403
+ if not node:
404
+ continue
405
+ for edge in graph.edges_from(node_id, kind="CALLS"):
406
+ target = graph.nodes.get(edge.dst)
407
+ if target and target.path != file_path:
408
+ callees.append({
409
+ "name": target.name,
410
+ "file": target.path,
411
+ "line": target.line,
412
+ })
413
+ break
414
+ for edge in graph.edges_to(node_id, kind="CALLS"):
415
+ source = graph.nodes.get(edge.src)
416
+ if source and source.path != file_path:
417
+ callers.append({
418
+ "name": source.name,
419
+ "file": source.path,
420
+ "line": source.line,
421
+ })
422
+ break
423
+ return callers[:10], callees[:10]
424
+
425
+ def _find_dependency_chain(
426
+ self, primary_files: set[str], graph: RepoGraph, depth: int = 1
427
+ ) -> list[str]:
428
+ chain: list[str] = []
429
+ visited: set[str] = set(primary_files)
430
+ for file_path in primary_files:
431
+ file_node = graph.find_file_node(file_path)
432
+ if file_node is None:
433
+ continue
434
+ expanded = graph.expand([file_node.id], max_hops=depth, max_size=50)
435
+ for nid, hop in expanded:
436
+ if hop == 0:
437
+ continue
438
+ node = graph.nodes.get(nid)
439
+ if node and node.path and node.path not in visited:
440
+ visited.add(node.path)
441
+ chain.append(node.path)
442
+ return chain[:10]
443
+
444
+ def _expand_context(
445
+ self, query: str, primary: list[SearchResult], graph: RepoGraph
446
+ ) -> dict:
447
+ primary_files = {r.chunk.file_path for r in primary}
448
+ test_files = self._find_test_files(primary_files)
449
+ imports, imported_by = self._find_imports_importers(primary_files, graph)
450
+ symbols = self._find_symbols(primary_files, graph)
451
+ callers, callees = self._find_callers_callees(primary_files, graph)
452
+ dependency_chain = self._find_dependency_chain(primary_files, graph, depth=1)
453
+
454
+ scores = [r.score for r in primary if r.score > 0]
455
+ confidence = sum(scores) / len(scores) if scores else 0.0
456
+
457
+ related_files = list(
458
+ primary_files | set(imports) | set(imported_by) | set(test_files)
459
+ )
460
+
461
+ return {
462
+ "query": query,
463
+ "confidence": round(confidence, 3),
464
+ "primary_chunks": primary,
465
+ "related_files": related_files,
466
+ "test_files": test_files,
467
+ "imports": imports,
468
+ "imported_by": imported_by,
469
+ "symbols": symbols,
470
+ "callers": callers,
471
+ "callees": callees,
472
+ "dependency_chain": dependency_chain,
473
+ "total_tokens": sum(len(r.chunk.content) for r in primary) // 4,
474
+ }
475
+
476
+ def _compress_pack(self, pack: dict) -> dict:
477
+ if not pack.get("primary_chunks"):
478
+ return pack
479
+ pack = dict(pack)
480
+ pack["primary_chunks"] = pack["primary_chunks"][:10]
481
+ pack["related_files"] = pack["related_files"][:5]
482
+ pack["test_files"] = pack["test_files"][:3]
483
+ pack["imports"] = pack["imports"][:2]
484
+ pack["imported_by"] = pack["imported_by"][:1]
485
+ pack["symbols"] = pack["symbols"][:5]
486
+ pack["callers"] = pack["callers"][:2]
487
+ pack["callees"] = pack["callees"][:2]
488
+ pack["dependency_chain"] = pack["dependency_chain"][:3]
489
+ pack["total_tokens"] = sum(len(r.chunk.content) for r in pack["primary_chunks"]) // 4
490
+ return pack
491
+
492
+ def _empty_pack(self, query: str) -> dict:
493
+ return {
494
+ "query": query,
495
+ "confidence": 0.0,
496
+ "primary_chunks": [],
497
+ "related_files": [],
498
+ "test_files": [],
499
+ "imports": [],
500
+ "imported_by": [],
501
+ "symbols": [],
502
+ "callers": [],
503
+ "callees": [],
504
+ "dependency_chain": [],
505
+ "total_tokens": 0,
506
+ }
507
+
508
+ def resolve(self, query: str, top_k: int = 5) -> dict:
509
+ """Full context resolution: search + expand + compress."""
510
+ if not self.chunks:
511
+ return self._empty_pack(query)
512
+ primary = self.search(query, top_k=top_k)
513
+ if not primary:
514
+ return self._empty_pack(query)
515
+ graph = self._build_repo_graph()
516
+ pack = self._expand_context(query, primary, graph)
517
+ return self._compress_pack(pack)
518
+
519
+ def explain(self, code_location: str) -> dict:
520
+ """Explain a code location: find symbol, expand context, return pack."""
521
+ query = code_location
522
+ if ":" in code_location:
523
+ parts = code_location.rsplit(":", 1)
524
+ if parts[0].endswith(
525
+ (".py", ".js", ".ts", ".rs", ".go", ".java", ".jsx", ".tsx")
526
+ ):
527
+ file_path = parts[0]
528
+ try:
529
+ line = int(parts[1])
530
+ except ValueError:
531
+ line = 0
532
+ chunks_here = [c for c in self.chunks if c.file_path == file_path]
533
+ if line > 0:
534
+ chunks_here = [
535
+ c for c in chunks_here if c.start_line <= line <= c.end_line
536
+ ]
537
+ if chunks_here:
538
+ primary = [
539
+ SearchResult(
540
+ chunk=chunks_here[0], score=1.0, source=SearchMode.SEMANTIC
541
+ )
542
+ ]
543
+ graph = self._build_repo_graph()
544
+ pack = self._expand_context(query, primary, graph)
545
+ return self._compress_pack(pack)
546
+ return self.resolve(code_location, top_k=3)
547
+
548
+ def format_context(self, pack: dict) -> str:
549
+ """Format a context pack as a human-readable string for agents."""
550
+ lines = [f"[{pack['confidence']:.2f}] {pack['query']}"]
551
+ if pack.get("primary_chunks"):
552
+ lines.append(f" ({len(pack['primary_chunks'])} files)")
553
+ lines.append("")
554
+ for i, result in enumerate(pack.get("primary_chunks", []), 1):
555
+ chunk = result.chunk
556
+ span = (
557
+ f"{chunk.start_line}-{chunk.end_line}"
558
+ if chunk.end_line != chunk.start_line
559
+ else str(chunk.start_line)
560
+ )
561
+ lines.append(f" {i}. {chunk.file_path}:{span} [{result.score:.2f}]")
562
+ snippet = chunk.content.strip()[:300]
563
+ for line in snippet.split("\n"):
564
+ lines.append(f" {line}")
565
+ if pack.get("symbols"):
566
+ names = [s["name"] for s in pack["symbols"][:6]]
567
+ if len(pack["symbols"]) > 6:
568
+ names.append("...")
569
+ lines.append(f" sym: {' '.join(names)}")
570
+ hints = []
571
+ if pack.get("test_files"):
572
+ hints.append(
573
+ f"tests: {', '.join(f.split('/')[-1] for f in pack['test_files'][:2])}"
574
+ )
575
+ if pack.get("imports"):
576
+ hints.append(
577
+ f"deps: {', '.join(f.split('/')[-1] for f in pack['imports'][:2])}"
578
+ )
579
+ if pack.get("callers"):
580
+ hints.append(
581
+ f"callers: {', '.join(c['name'] for c in pack['callers'][:2])}"
582
+ )
583
+ if hints:
584
+ lines.append(f" {' | '.join(hints)}")
585
+ return "\n".join(lines)
586
+
306
587
  def find_related(self, chunk_idx: int, top_k: int = 5) -> list[SearchResult]:
307
588
  """Find chunks semantically similar to a given chunk (cocoindex find_related)."""
308
589
  if chunk_idx < 0 or chunk_idx >= len(self.chunks):
@@ -96,3 +96,12 @@ class IndexStats:
96
96
  memo_hits: int = 0 # Chunks skipped due to memoization
97
97
  memo_misses: int = 0 # Chunks re-embedded
98
98
  index_time_ms: float = 0 # Elapsed wall-clock time for the index run
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class GraphContext:
103
+ """Compact structural context for one search-result file."""
104
+
105
+ key_symbol: str = ""
106
+ incoming: tuple[str, ...] = ()
107
+ outgoing: tuple[str, ...] = ()