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.
- {vortexa-0.3.3 → vortexa-0.3.5}/PKG-INFO +58 -13
- {vortexa-0.3.3 → vortexa-0.3.5}/README.md +57 -12
- {vortexa-0.3.3 → vortexa-0.3.5}/pyproject.toml +1 -1
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/graph.py +19 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/indexer.py +283 -2
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/inference.py +46 -11
- vortexa-0.3.5/src/vortexa/core/lf2_model.py +320 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/types.py +9 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/v4_embedder.py +46 -10
- vortexa-0.3.5/src/vortexa/interfaces/cli.py +674 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/interfaces/mcp_server.py +55 -4
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa.egg-info/PKG-INFO +58 -13
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa.egg-info/SOURCES.txt +4 -1
- vortexa-0.3.5/tests/test_lf2_model.py +121 -0
- vortexa-0.3.5/tests/test_mcp_server.py +10 -0
- vortexa-0.3.3/src/vortexa/interfaces/cli.py +0 -307
- {vortexa-0.3.3 → vortexa-0.3.5}/LICENSE +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/setup.cfg +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/__init__.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/__init__.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/chunking.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/context_engine.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/embedding.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/language.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/lf4_v4_model.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/core/vortex_score.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/interfaces/__init__.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/interfaces/watcher.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/search/__init__.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/search/ranking.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/search/search.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/search/tokens.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/storage/__init__.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/storage/bm25.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/storage/vector_store.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa/storage/walker.py +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa.egg-info/dependency_links.txt +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa.egg-info/entry_points.txt +0 -0
- {vortexa-0.3.3 → vortexa-0.3.5}/src/vortexa.egg-info/requires.txt +0 -0
- {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
|
+
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 (
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
348
|
-
| `nano` | `VTXAI/vtx-embed-1M` |
|
|
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="
|
|
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="
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
313
|
-
| `nano` | `VTXAI/vtx-embed-1M` |
|
|
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="
|
|
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="
|
|
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
|
|
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
|
|
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 |
|
|
@@ -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):
|