vortexa 0.3.3__tar.gz → 0.3.5__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 (40) hide show
  1. {vortexa-0.3.3 → vortexa-0.3.5}/PKG-INFO +58 -13
  2. {vortexa-0.3.3 → vortexa-0.3.5}/README.md +57 -12
  3. {vortexa-0.3.3 → vortexa-0.3.5}/pyproject.toml +1 -1
  4. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/graph.py +19 -0
  5. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/indexer.py +283 -2
  6. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/inference.py +46 -11
  7. vortexa-0.3.5/src/vortexa/core/lf2_model.py +320 -0
  8. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/types.py +9 -0
  9. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/v4_embedder.py +46 -10
  10. vortexa-0.3.5/src/vortexa/interfaces/cli.py +674 -0
  11. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/interfaces/mcp_server.py +55 -4
  12. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa.egg-info/PKG-INFO +58 -13
  13. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa.egg-info/SOURCES.txt +4 -1
  14. vortexa-0.3.5/tests/test_lf2_model.py +121 -0
  15. vortexa-0.3.5/tests/test_mcp_server.py +10 -0
  16. vortexa-0.3.3/src/vortexa/interfaces/cli.py +0 -307
  17. {vortexa-0.3.3 → vortexa-0.3.5}/LICENSE +0 -0
  18. {vortexa-0.3.3 → vortexa-0.3.5}/setup.cfg +0 -0
  19. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/__init__.py +0 -0
  20. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/__init__.py +0 -0
  21. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/chunking.py +0 -0
  22. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/context_engine.py +0 -0
  23. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/embedding.py +0 -0
  24. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/language.py +0 -0
  25. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/lf4_v4_model.py +0 -0
  26. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/vortex_score.py +0 -0
  27. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/interfaces/__init__.py +0 -0
  28. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/interfaces/watcher.py +0 -0
  29. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/search/__init__.py +0 -0
  30. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/search/ranking.py +0 -0
  31. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/search/search.py +0 -0
  32. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/search/tokens.py +0 -0
  33. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/storage/__init__.py +0 -0
  34. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/storage/bm25.py +0 -0
  35. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/storage/vector_store.py +0 -0
  36. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/storage/walker.py +0 -0
  37. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa.egg-info/dependency_links.txt +0 -0
  38. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa.egg-info/entry_points.txt +0 -0
  39. {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa.egg-info/requires.txt +0 -0
  40. {vortexa-0.3.3 → vortexa-0.3.5}/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.5
4
4
  Summary: Codebase indexing and semantic search engine
5
5
  Author-email: VortexAI <koulabhay25@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -63,6 +63,7 @@ _Dense + sparse hybrid retrieval · AST-aware chunking · LMDB persistence · MC
63
63
  - [CLI Search](#cli-search)
64
64
  - [MCP Server](#mcp-server)
65
65
  - [Usage with Claude Code / Cursor](#usage-with-claude-code--cursor)
66
+ - [Agent Skill](#agent-skill)
66
67
  - [Architecture](#architecture)
67
68
  - [Dependencies](#dependencies)
68
69
  - [License](#license)
@@ -77,7 +78,7 @@ _Dense + sparse hybrid retrieval · AST-aware chunking · LMDB persistence · MC
77
78
 
78
79
  vortexa is a standalone **codebase indexing and semantic search engine** designed for AI agents and developers. It builds a persistent, hybrid search index over source code using:
79
80
 
80
- - **Dense retrieval** via VortexEmbedderV4 (on-the-fly LF4 4-bit dequant, SIF+PC, Matryoshka) or static embeddings (Model2Vec / SentenceTransformers)
81
+ - **Dense retrieval** via VortexEmbedderV4 (native LF2/LF4 dequant, SIF+PC, Matryoshka) or static embeddings (Model2Vec / SentenceTransformers)
81
82
  - **Sparse retrieval** via BM25 keyword scoring
82
83
  - **AST-aware chunking** that respects function and class boundaries via tree-sitter
83
84
  - **LMDB-backed storage** for fast, persistent vector and chunk storage
@@ -126,7 +127,7 @@ vortexa can run as a **standalone Python library**, be embedded into any agent,
126
127
  </tr>
127
128
  <tr>
128
129
  <td><strong>MCP server</strong></td>
129
- <td>Expose as a single <code>search</code> tool for any MCP-compatible agent (Claude Code, Cursor, etc.)</td>
130
+ <td>Expose <code>search</code>, <code>resolve</code>, and <code>explain</code> tools for MCP-compatible agents (Claude Code, Cursor, etc.)</td>
130
131
  </tr>
131
132
  <tr>
132
133
  <td><strong>Zero mandatory heavy deps</strong></td>
@@ -310,7 +311,8 @@ Useful flags:
310
311
  | `--force` | Force a full re-index before searching. |
311
312
  | `--no-index` | Search the existing index only. |
312
313
  | `--plain` | Print human-readable results instead of JSON. |
313
- | `--model` | Embedding model ID or alias (`mini`, `nano`). Default: `mini`. |
314
+ | `--model` | Embedding model ID or alias (`mini`, `nano`; both use LF2 by default). Use `mini-full` or `nano-full` for LF4. |
315
+ | `-f`, `--fast` | Explicitly select LF2 for a full-model alias. The `mini` and `nano` aliases already use LF2. |
314
316
 
315
317
  By default CLI output is JSON:
316
318
 
@@ -338,14 +340,18 @@ vortexa-serve
338
340
 
339
341
  ## Model Configuration
340
342
 
341
- vortexa supports configurable embedding models. The default is `mini` (`VTXAI/vtx-embed-7M`).
343
+ vortexa supports configurable embedding models. The default is `mini`
344
+ (`VTXAI/vtx-embed-7M-lf2`), using the native LF2 2-bit checkpoint. Use
345
+ `mini-full` or `nano-full` to select the original LF4 checkpoints.
342
346
 
343
347
  ### Available Models
344
348
 
345
349
  | Alias | Model ID | Description |
346
350
  |-------|----------|-------------|
347
- | `mini` | `VTXAI/vtx-embed-7M` | Default. 7M-parameter Vortex-Embed v4.5 with LF4 4-bit dequant, SIF+PC, Matryoshka. |
348
- | `nano` | `VTXAI/vtx-embed-1M` | Lightweight 1M-parameter variant. Lower RAM, lower dim. |
351
+ | `mini` | `VTXAI/vtx-embed-7M-lf2` | Default native 2-bit 7M model. 256-dimensional embeddings. |
352
+ | `nano` | `VTXAI/vtx-embed-1M-lf2` | Default native 2-bit 1M model. 64-dimensional embeddings. |
353
+ | `mini-full` | `VTXAI/vtx-embed-7M` | Original LF4 checkpoint for maximum quality. |
354
+ | `nano-full` | `VTXAI/vtx-embed-1M` | Original LF4 lightweight checkpoint. |
349
355
 
350
356
  ### CLI Usage
351
357
 
@@ -365,11 +371,14 @@ vortexa -q "authentication" --model VTXAI/vtx-embed-1M /path/to/project
365
371
  ```python
366
372
  from vortexa.core.indexer import CodebaseIndexer
367
373
 
368
- # Use nano model
369
- indexer = CodebaseIndexer(root="/path/to/project", model_id="VTXAI/vtx-embed-1M")
374
+ # Use nano model (LF2 by default)
375
+ indexer = CodebaseIndexer(root="/path/to/project", model_id="nano")
370
376
 
371
- # Use mini model (default)
372
- indexer = CodebaseIndexer(root="/path/to/project", model_id="VTXAI/vtx-embed-7M")
377
+ # Use mini model (LF2 by default)
378
+ indexer = CodebaseIndexer(root="/path/to/project", model_id="mini")
379
+
380
+ # Opt into the original LF4 checkpoint
381
+ indexer = CodebaseIndexer(root="/path/to/project", model_id="mini-full")
373
382
  ```
374
383
 
375
384
  ### Alternative Embedders
@@ -441,11 +450,17 @@ On startup it indexes the current working directory and prints stats to stderr:
441
450
  [vortexa] Auto-reindex watcher started (polling every 3s)
442
451
  ```
443
452
 
444
- The server exposes one tool:
453
+ The server exposes three tools:
445
454
 
446
455
  | Tool | Description | Arguments |
447
456
  |------|-------------|-----------|
448
457
  | `search` | Semantic + BM25 hybrid code search | `query` (str), `top_k` (int, default 10) |
458
+ | `resolve` | Feature-level search with graph context, tests, imports, callers, and callees | `query` (str), `top_k` (int, default 5) |
459
+ | `explain` | Explain a file, `file:line` location, or symbol | `location` (str) |
460
+
461
+ Use `search` for discovery, `resolve` when you need feature context, and
462
+ `explain` when the file or symbol is already known. The server indexes the
463
+ current directory and auto-reindexes it while running.
449
464
 
450
465
  ### Usage with Claude Code / Cursor
451
466
 
@@ -469,6 +484,36 @@ The agent will now have access to semantic code search — it can find functions
469
484
 
470
485
  <div align="center">
471
486
 
487
+ ## Agent Skill
488
+
489
+ </div>
490
+
491
+ Vortexa ships with an Agent Skills-compatible skill at
492
+ [`skills/vortexa/SKILL.md`](skills/vortexa/SKILL.md). It teaches compatible
493
+ agents when to prefer Vortexa, how to choose between `search`, `resolve`, and
494
+ `explain`, and how to interpret returned file paths, line ranges, scores, and
495
+ graph context.
496
+
497
+ The skill follows the standard `SKILL.md` structure: YAML frontmatter with a
498
+ lowercase hyphenated name and trigger-oriented description, followed by
499
+ workflow instructions. Copy or link the `skills/vortexa` directory into the
500
+ skills directory used by your agent environment.
501
+
502
+ ```markdown
503
+ ---
504
+ name: vortexa
505
+ description: Use Vortexa to index and semantically search local codebases...
506
+ ---
507
+ ```
508
+
509
+ Use the installed MCP server when available; otherwise the same workflow is
510
+ available through the `vortexa search`, `vortexa resolve`, and
511
+ `vortexa explain` commands.
512
+
513
+ ---
514
+
515
+ <div align="center">
516
+
472
517
  ## Architecture
473
518
 
474
519
  </div>
@@ -613,7 +658,7 @@ graph TD
613
658
  | `bm25s` | Yes | Fast BM25 keyword index and persistence |
614
659
  | `pathspec` | Yes | `.gitignore` pattern matching in file walker |
615
660
  | `model2vec` | Optional | Alternative static embeddings |
616
- | `huggingface-hub` | Yes (default model) | Loading `VTXAI/vtx-embed-7M` (mini) or `VTXAI/vtx-embed-1M` (nano) |
661
+ | `huggingface-hub` | Yes (default model) | Loading LF2 defaults (`VTXAI/vtx-embed-7M-lf2`, `VTXAI/vtx-embed-1M-lf2`) |
617
662
  | `tokenizers` | Yes (default model) | HF tokenizer for embedding model |
618
663
  | `safetensors` | Yes (default model) | Safe tensor loading for 4-bit weights |
619
664
  | `sentence-transformers` | Optional | Transformer-based dense embeddings |
@@ -28,6 +28,7 @@ _Dense + sparse hybrid retrieval · AST-aware chunking · LMDB persistence · MC
28
28
  - [CLI Search](#cli-search)
29
29
  - [MCP Server](#mcp-server)
30
30
  - [Usage with Claude Code / Cursor](#usage-with-claude-code--cursor)
31
+ - [Agent Skill](#agent-skill)
31
32
  - [Architecture](#architecture)
32
33
  - [Dependencies](#dependencies)
33
34
  - [License](#license)
@@ -42,7 +43,7 @@ _Dense + sparse hybrid retrieval · AST-aware chunking · LMDB persistence · MC
42
43
 
43
44
  vortexa is a standalone **codebase indexing and semantic search engine** designed for AI agents and developers. It builds a persistent, hybrid search index over source code using:
44
45
 
45
- - **Dense retrieval** via VortexEmbedderV4 (on-the-fly LF4 4-bit dequant, SIF+PC, Matryoshka) or static embeddings (Model2Vec / SentenceTransformers)
46
+ - **Dense retrieval** via VortexEmbedderV4 (native LF2/LF4 dequant, SIF+PC, Matryoshka) or static embeddings (Model2Vec / SentenceTransformers)
46
47
  - **Sparse retrieval** via BM25 keyword scoring
47
48
  - **AST-aware chunking** that respects function and class boundaries via tree-sitter
48
49
  - **LMDB-backed storage** for fast, persistent vector and chunk storage
@@ -91,7 +92,7 @@ vortexa can run as a **standalone Python library**, be embedded into any agent,
91
92
  </tr>
92
93
  <tr>
93
94
  <td><strong>MCP server</strong></td>
94
- <td>Expose as a single <code>search</code> tool for any MCP-compatible agent (Claude Code, Cursor, etc.)</td>
95
+ <td>Expose <code>search</code>, <code>resolve</code>, and <code>explain</code> tools for MCP-compatible agents (Claude Code, Cursor, etc.)</td>
95
96
  </tr>
96
97
  <tr>
97
98
  <td><strong>Zero mandatory heavy deps</strong></td>
@@ -275,7 +276,8 @@ Useful flags:
275
276
  | `--force` | Force a full re-index before searching. |
276
277
  | `--no-index` | Search the existing index only. |
277
278
  | `--plain` | Print human-readable results instead of JSON. |
278
- | `--model` | Embedding model ID or alias (`mini`, `nano`). Default: `mini`. |
279
+ | `--model` | Embedding model ID or alias (`mini`, `nano`; both use LF2 by default). Use `mini-full` or `nano-full` for LF4. |
280
+ | `-f`, `--fast` | Explicitly select LF2 for a full-model alias. The `mini` and `nano` aliases already use LF2. |
279
281
 
280
282
  By default CLI output is JSON:
281
283
 
@@ -303,14 +305,18 @@ vortexa-serve
303
305
 
304
306
  ## Model Configuration
305
307
 
306
- vortexa supports configurable embedding models. The default is `mini` (`VTXAI/vtx-embed-7M`).
308
+ vortexa supports configurable embedding models. The default is `mini`
309
+ (`VTXAI/vtx-embed-7M-lf2`), using the native LF2 2-bit checkpoint. Use
310
+ `mini-full` or `nano-full` to select the original LF4 checkpoints.
307
311
 
308
312
  ### Available Models
309
313
 
310
314
  | Alias | Model ID | Description |
311
315
  |-------|----------|-------------|
312
- | `mini` | `VTXAI/vtx-embed-7M` | Default. 7M-parameter Vortex-Embed v4.5 with LF4 4-bit dequant, SIF+PC, Matryoshka. |
313
- | `nano` | `VTXAI/vtx-embed-1M` | Lightweight 1M-parameter variant. Lower RAM, lower dim. |
316
+ | `mini` | `VTXAI/vtx-embed-7M-lf2` | Default native 2-bit 7M model. 256-dimensional embeddings. |
317
+ | `nano` | `VTXAI/vtx-embed-1M-lf2` | Default native 2-bit 1M model. 64-dimensional embeddings. |
318
+ | `mini-full` | `VTXAI/vtx-embed-7M` | Original LF4 checkpoint for maximum quality. |
319
+ | `nano-full` | `VTXAI/vtx-embed-1M` | Original LF4 lightweight checkpoint. |
314
320
 
315
321
  ### CLI Usage
316
322
 
@@ -330,11 +336,14 @@ vortexa -q "authentication" --model VTXAI/vtx-embed-1M /path/to/project
330
336
  ```python
331
337
  from vortexa.core.indexer import CodebaseIndexer
332
338
 
333
- # Use nano model
334
- indexer = CodebaseIndexer(root="/path/to/project", model_id="VTXAI/vtx-embed-1M")
339
+ # Use nano model (LF2 by default)
340
+ indexer = CodebaseIndexer(root="/path/to/project", model_id="nano")
335
341
 
336
- # Use mini model (default)
337
- indexer = CodebaseIndexer(root="/path/to/project", model_id="VTXAI/vtx-embed-7M")
342
+ # Use mini model (LF2 by default)
343
+ indexer = CodebaseIndexer(root="/path/to/project", model_id="mini")
344
+
345
+ # Opt into the original LF4 checkpoint
346
+ indexer = CodebaseIndexer(root="/path/to/project", model_id="mini-full")
338
347
  ```
339
348
 
340
349
  ### Alternative Embedders
@@ -406,11 +415,17 @@ On startup it indexes the current working directory and prints stats to stderr:
406
415
  [vortexa] Auto-reindex watcher started (polling every 3s)
407
416
  ```
408
417
 
409
- The server exposes one tool:
418
+ The server exposes three tools:
410
419
 
411
420
  | Tool | Description | Arguments |
412
421
  |------|-------------|-----------|
413
422
  | `search` | Semantic + BM25 hybrid code search | `query` (str), `top_k` (int, default 10) |
423
+ | `resolve` | Feature-level search with graph context, tests, imports, callers, and callees | `query` (str), `top_k` (int, default 5) |
424
+ | `explain` | Explain a file, `file:line` location, or symbol | `location` (str) |
425
+
426
+ Use `search` for discovery, `resolve` when you need feature context, and
427
+ `explain` when the file or symbol is already known. The server indexes the
428
+ current directory and auto-reindexes it while running.
414
429
 
415
430
  ### Usage with Claude Code / Cursor
416
431
 
@@ -434,6 +449,36 @@ The agent will now have access to semantic code search — it can find functions
434
449
 
435
450
  <div align="center">
436
451
 
452
+ ## Agent Skill
453
+
454
+ </div>
455
+
456
+ Vortexa ships with an Agent Skills-compatible skill at
457
+ [`skills/vortexa/SKILL.md`](skills/vortexa/SKILL.md). It teaches compatible
458
+ agents when to prefer Vortexa, how to choose between `search`, `resolve`, and
459
+ `explain`, and how to interpret returned file paths, line ranges, scores, and
460
+ graph context.
461
+
462
+ The skill follows the standard `SKILL.md` structure: YAML frontmatter with a
463
+ lowercase hyphenated name and trigger-oriented description, followed by
464
+ workflow instructions. Copy or link the `skills/vortexa` directory into the
465
+ skills directory used by your agent environment.
466
+
467
+ ```markdown
468
+ ---
469
+ name: vortexa
470
+ description: Use Vortexa to index and semantically search local codebases...
471
+ ---
472
+ ```
473
+
474
+ Use the installed MCP server when available; otherwise the same workflow is
475
+ available through the `vortexa search`, `vortexa resolve`, and
476
+ `vortexa explain` commands.
477
+
478
+ ---
479
+
480
+ <div align="center">
481
+
437
482
  ## Architecture
438
483
 
439
484
  </div>
@@ -578,7 +623,7 @@ graph TD
578
623
  | `bm25s` | Yes | Fast BM25 keyword index and persistence |
579
624
  | `pathspec` | Yes | `.gitignore` pattern matching in file walker |
580
625
  | `model2vec` | Optional | Alternative static embeddings |
581
- | `huggingface-hub` | Yes (default model) | Loading `VTXAI/vtx-embed-7M` (mini) or `VTXAI/vtx-embed-1M` (nano) |
626
+ | `huggingface-hub` | Yes (default model) | Loading LF2 defaults (`VTXAI/vtx-embed-7M-lf2`, `VTXAI/vtx-embed-1M-lf2`) |
582
627
  | `tokenizers` | Yes (default model) | HF tokenizer for embedding model |
583
628
  | `safetensors` | Yes (default model) | Safe tensor loading for 4-bit weights |
584
629
  | `sentence-transformers` | Optional | Transformer-based dense embeddings |
@@ -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.5"
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
@@ -84,7 +87,7 @@ class CodebaseIndexer:
84
87
  self,
85
88
  root: str | Path,
86
89
  model: Encoder | Embedder | None = None,
87
- model_id: str = "VTXAI/vtx-embed-7M",
90
+ model_id: str = "VTXAI/vtx-embed-7M-lf2",
88
91
  index_dir: str | Path | None = None,
89
92
  chunk_config: ChunkConfig | None = None,
90
93
  ) -> None:
@@ -110,7 +113,7 @@ class CodebaseIndexer:
110
113
  self._model = model
111
114
  else:
112
115
  from vortexa.core.v4_embedder import VortexEmbedderV4
113
- self._embedder = VortexEmbedderV4(model_id or "VTXAI/vtx-embed-7M")
116
+ self._embedder = VortexEmbedderV4(model_id or "VTXAI/vtx-embed-7M-lf2")
114
117
  self._model = self._embedder
115
118
 
116
119
  # In-memory state
@@ -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):