trelix 0.5.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.
- trelix/__init__.py +20 -0
- trelix/cli/__init__.py +1 -0
- trelix/cli/main.py +560 -0
- trelix/core/__init__.py +0 -0
- trelix/core/config.py +332 -0
- trelix/core/models.py +252 -0
- trelix/embedder/__init__.py +17 -0
- trelix/embedder/base.py +362 -0
- trelix/indexing/__init__.py +0 -0
- trelix/indexing/chunker.py +272 -0
- trelix/indexing/indexer.py +839 -0
- trelix/indexing/parser/__init__.py +0 -0
- trelix/indexing/parser/base.py +53 -0
- trelix/indexing/parser/extractors/__init__.py +0 -0
- trelix/indexing/parser/extractors/c.py +858 -0
- trelix/indexing/parser/extractors/cpp.py +800 -0
- trelix/indexing/parser/extractors/csharp.py +1174 -0
- trelix/indexing/parser/extractors/cshtml.py +238 -0
- trelix/indexing/parser/extractors/csproj.py +203 -0
- trelix/indexing/parser/extractors/css.py +511 -0
- trelix/indexing/parser/extractors/go.py +714 -0
- trelix/indexing/parser/extractors/html.py +661 -0
- trelix/indexing/parser/extractors/java.py +847 -0
- trelix/indexing/parser/extractors/javascript.py +783 -0
- trelix/indexing/parser/extractors/json_config.py +373 -0
- trelix/indexing/parser/extractors/kotlin.py +812 -0
- trelix/indexing/parser/extractors/markdown.py +276 -0
- trelix/indexing/parser/extractors/python.py +1084 -0
- trelix/indexing/parser/extractors/razor.py +502 -0
- trelix/indexing/parser/extractors/ruby.py +764 -0
- trelix/indexing/parser/extractors/rust.py +1037 -0
- trelix/indexing/parser/extractors/toml_config.py +384 -0
- trelix/indexing/parser/extractors/typescript.py +1279 -0
- trelix/indexing/parser/extractors/yaml_config.py +252 -0
- trelix/indexing/parser/registry.py +189 -0
- trelix/indexing/walker.py +171 -0
- trelix/indexing/watcher.py +302 -0
- trelix/retrieval/__init__.py +0 -0
- trelix/retrieval/assembler.py +207 -0
- trelix/retrieval/bm25.py +193 -0
- trelix/retrieval/fusion.py +61 -0
- trelix/retrieval/graph.py +364 -0
- trelix/retrieval/graph_rag.py +250 -0
- trelix/retrieval/grep_search.py +170 -0
- trelix/retrieval/planner/__init__.py +0 -0
- trelix/retrieval/planner/agent.py +475 -0
- trelix/retrieval/planner/models.py +226 -0
- trelix/retrieval/planner/prompts.py +193 -0
- trelix/retrieval/reranker.py +218 -0
- trelix/retrieval/retriever.py +633 -0
- trelix/retrieval/synthesizer.py +266 -0
- trelix/store/__init__.py +0 -0
- trelix/store/db.py +1220 -0
- trelix/store/vector.py +316 -0
- trelix/store/vector_qdrant.py +147 -0
- trelix-0.5.0.dist-info/METADATA +471 -0
- trelix-0.5.0.dist-info/RECORD +60 -0
- trelix-0.5.0.dist-info/WHEEL +4 -0
- trelix-0.5.0.dist-info/entry_points.txt +2 -0
- trelix-0.5.0.dist-info/licenses/LICENSE +21 -0
trelix/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
trelix — fast, reliable code indexing and retrieval.
|
|
3
|
+
|
|
4
|
+
Tree-sitter AST parsing → contextual hybrid search (vector + BM25 + grep)
|
|
5
|
+
→ adaptive 3-tier query planning → call-graph expansion
|
|
6
|
+
→ GraphRAG synthesis.
|
|
7
|
+
|
|
8
|
+
Quick start:
|
|
9
|
+
from trelix.core.config import IndexConfig
|
|
10
|
+
from trelix.indexing.indexer import Indexer
|
|
11
|
+
from trelix.retrieval.retriever import Retriever
|
|
12
|
+
|
|
13
|
+
config = IndexConfig(repo_path="/path/to/repo")
|
|
14
|
+
Indexer(config).index()
|
|
15
|
+
ctx = Retriever(config).retrieve("how does authentication work?")
|
|
16
|
+
print(ctx.context_text)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__version__ = "0.5.0"
|
|
20
|
+
__all__ = ["__version__"]
|
trelix/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""trelix CLI — entry point is trelix.cli.main:app"""
|
trelix/cli/main.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""
|
|
2
|
+
trelix CLI — Phase 14 full implementation.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
trelix index <repo> [--provider local|openai|azure] [-v]
|
|
6
|
+
trelix search <repo> <query> [--provider ...] [--json]
|
|
7
|
+
trelix ask <repo> <query> [--provider ...]
|
|
8
|
+
trelix query <repo> <query> [--provider ...]
|
|
9
|
+
trelix stats <repo>
|
|
10
|
+
trelix update-index <repo> <file>
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal, cast
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
|
|
25
|
+
from rich.table import Table
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="trelix",
|
|
29
|
+
help="Fast, reliable code indexing and retrieval.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
rich_markup_mode="rich",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
console = Console()
|
|
35
|
+
err_console = Console(stderr=True)
|
|
36
|
+
|
|
37
|
+
_EmbedderProvider = Literal["openai", "azure", "local", "voyage", "local-code"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Logging helpers
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _setup_logging(verbose: bool = False) -> None:
|
|
46
|
+
"""Configure the trelix logger. Call once at CLI entry."""
|
|
47
|
+
level = logging.DEBUG if verbose else logging.WARNING
|
|
48
|
+
logging.basicConfig(
|
|
49
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
50
|
+
datefmt="%H:%M:%S",
|
|
51
|
+
level=level,
|
|
52
|
+
)
|
|
53
|
+
for lib in ("httpx", "httpcore", "openai", "sentence_transformers", "transformers"):
|
|
54
|
+
logging.getLogger(lib).setLevel(logging.WARNING)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# index
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def index(
|
|
64
|
+
repo: str = typer.Argument(..., help="Path to the repository to index"),
|
|
65
|
+
provider: str = typer.Option("local", help="Embedding provider: local | openai | azure"),
|
|
66
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed progress"),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Index a repository — builds the search index at <repo>/.trelix/index.db"""
|
|
69
|
+
_setup_logging(verbose)
|
|
70
|
+
|
|
71
|
+
from trelix.core.config import EmbedderConfig, IndexConfig
|
|
72
|
+
from trelix.indexing.indexer import Indexer
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
config = IndexConfig(
|
|
76
|
+
repo_path=str(Path(repo).resolve()),
|
|
77
|
+
embedder=EmbedderConfig(provider=cast(_EmbedderProvider, provider)),
|
|
78
|
+
)
|
|
79
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
80
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
81
|
+
raise typer.Exit(1) from exc
|
|
82
|
+
|
|
83
|
+
console.print(Panel(f"[bold cyan]Indexing[/bold cyan] {repo}", expand=False))
|
|
84
|
+
|
|
85
|
+
t0 = time.perf_counter()
|
|
86
|
+
try:
|
|
87
|
+
indexer = Indexer(config)
|
|
88
|
+
stats = indexer.index()
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
err_console.print(f"[red]Indexing failed:[/red] {exc}")
|
|
91
|
+
raise typer.Exit(1) from exc
|
|
92
|
+
|
|
93
|
+
elapsed = time.perf_counter() - t0
|
|
94
|
+
|
|
95
|
+
table = Table(title="Index Summary", show_header=True, header_style="bold cyan")
|
|
96
|
+
table.add_column("Metric", style="dim")
|
|
97
|
+
table.add_column("Value", justify="right")
|
|
98
|
+
table.add_row("Files found", str(stats.get("files_found", 0)))
|
|
99
|
+
table.add_row("Files indexed", str(stats.get("files_indexed", 0)))
|
|
100
|
+
table.add_row("Files skipped", str(stats.get("files_skipped", 0)))
|
|
101
|
+
table.add_row("Symbols extracted", str(stats.get("symbols_extracted", 0)))
|
|
102
|
+
table.add_row("Chunks embedded", str(stats.get("chunks_embedded", 0)))
|
|
103
|
+
table.add_row("Elapsed", f"{elapsed:.1f}s")
|
|
104
|
+
if stats.get("errors"):
|
|
105
|
+
table.add_row("[red]Errors[/red]", f"[red]{stats['errors']}[/red]")
|
|
106
|
+
console.print(table)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# search
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command()
|
|
115
|
+
def search(
|
|
116
|
+
repo: str = typer.Argument(..., help="Path to the indexed repository"),
|
|
117
|
+
query: str = typer.Argument(..., help="Natural language query"),
|
|
118
|
+
provider: str = typer.Option("local", help="Embedding provider: local | openai | azure"),
|
|
119
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Search for code — returns ranked results as a table or JSON"""
|
|
122
|
+
_setup_logging(False)
|
|
123
|
+
|
|
124
|
+
from trelix.core.config import EmbedderConfig, IndexConfig, RetrievalConfig
|
|
125
|
+
from trelix.retrieval.retriever import Retriever
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
config = IndexConfig(
|
|
129
|
+
repo_path=str(Path(repo).resolve()),
|
|
130
|
+
embedder=EmbedderConfig(provider=cast(_EmbedderProvider, provider)),
|
|
131
|
+
retrieval=RetrievalConfig(rerank=False),
|
|
132
|
+
)
|
|
133
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
134
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
135
|
+
raise typer.Exit(1) from exc
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
retriever = Retriever(config)
|
|
139
|
+
context = retriever.retrieve(query)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
err_console.print(f"[red]Search failed:[/red] {exc}")
|
|
142
|
+
raise typer.Exit(1) from exc
|
|
143
|
+
|
|
144
|
+
if json_output:
|
|
145
|
+
results_json = []
|
|
146
|
+
for r in context.results:
|
|
147
|
+
results_json.append(
|
|
148
|
+
{
|
|
149
|
+
"file": r.file.rel_path,
|
|
150
|
+
"symbol": r.symbol.name,
|
|
151
|
+
"lines": f"{r.symbol.line_start}-{r.symbol.line_end}",
|
|
152
|
+
"score": round(r.score, 4),
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
print(json.dumps({"status": "ok", "results": results_json}))
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
table = Table(title=f"Search: {query}", show_header=True, header_style="bold cyan")
|
|
159
|
+
table.add_column("File", style="dim", max_width=40)
|
|
160
|
+
table.add_column("Symbol", style="bold")
|
|
161
|
+
table.add_column("Lines", justify="right")
|
|
162
|
+
table.add_column("Score", justify="right")
|
|
163
|
+
|
|
164
|
+
for r in context.results:
|
|
165
|
+
table.add_row(
|
|
166
|
+
r.file.rel_path,
|
|
167
|
+
r.symbol.name,
|
|
168
|
+
f"{r.symbol.line_start}-{r.symbol.line_end}",
|
|
169
|
+
f"{r.score:.4f}",
|
|
170
|
+
)
|
|
171
|
+
console.print(table)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# ask
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.command()
|
|
180
|
+
def ask(
|
|
181
|
+
repo: str = typer.Argument(..., help="Path to the indexed repository"),
|
|
182
|
+
query: str = typer.Argument(..., help="Question to answer about the codebase"),
|
|
183
|
+
provider: str = typer.Option("local", help="Embedding provider: local | openai | azure"),
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Ask a question — retrieval + LLM synthesis (requires OPENAI_API_KEY for full synthesis)"""
|
|
186
|
+
_setup_logging(False)
|
|
187
|
+
|
|
188
|
+
from trelix.core.config import EmbedderConfig, IndexConfig, RetrievalConfig
|
|
189
|
+
from trelix.retrieval.retriever import Retriever
|
|
190
|
+
from trelix.retrieval.synthesizer import Synthesizer
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
config = IndexConfig(
|
|
194
|
+
repo_path=str(Path(repo).resolve()),
|
|
195
|
+
embedder=EmbedderConfig(provider=cast(_EmbedderProvider, provider)),
|
|
196
|
+
retrieval=RetrievalConfig(rerank=False),
|
|
197
|
+
)
|
|
198
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
199
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
200
|
+
raise typer.Exit(1) from exc
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
retriever = Retriever(config)
|
|
204
|
+
context = retriever.retrieve(query)
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
err_console.print(f"[red]Retrieval failed:[/red] {exc}")
|
|
207
|
+
raise typer.Exit(1) from exc
|
|
208
|
+
|
|
209
|
+
# If provider=local (no API key), print the context text directly
|
|
210
|
+
if provider == "local":
|
|
211
|
+
console.print(Panel(f"[bold cyan]Context for:[/bold cyan] {query}", expand=False))
|
|
212
|
+
if context.context_text:
|
|
213
|
+
console.print(context.context_text)
|
|
214
|
+
else:
|
|
215
|
+
console.print("[yellow]No relevant code found.[/yellow]")
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
synth = Synthesizer(config.embedder)
|
|
220
|
+
synth.synthesize(context)
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
err_console.print(f"[red]Synthesis failed:[/red] {exc}")
|
|
223
|
+
raise typer.Exit(1) from exc
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# query (human-readable, always Rich, no --json flag)
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.command()
|
|
232
|
+
def query(
|
|
233
|
+
repo: str = typer.Argument(..., help="Path to the indexed repository"),
|
|
234
|
+
query_str: str = typer.Argument(..., metavar="QUERY", help="Natural language query"),
|
|
235
|
+
provider: str = typer.Option("local", help="Embedding provider: local | openai | azure"),
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Query a repository — human-readable Rich terminal output (no LLM synthesis)"""
|
|
238
|
+
_setup_logging(False)
|
|
239
|
+
|
|
240
|
+
from trelix.core.config import EmbedderConfig, IndexConfig, RetrievalConfig
|
|
241
|
+
from trelix.retrieval.retriever import Retriever
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
config = IndexConfig(
|
|
245
|
+
repo_path=str(Path(repo).resolve()),
|
|
246
|
+
embedder=EmbedderConfig(provider=cast(_EmbedderProvider, provider)),
|
|
247
|
+
retrieval=RetrievalConfig(rerank=False),
|
|
248
|
+
)
|
|
249
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
250
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
251
|
+
raise typer.Exit(1) from exc
|
|
252
|
+
|
|
253
|
+
console.print(Panel(f"[bold cyan]Query:[/bold cyan] {query_str}", expand=False))
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
retriever = Retriever(config)
|
|
257
|
+
context = retriever.retrieve(query_str)
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
err_console.print(f"[red]Query failed:[/red] {exc}")
|
|
260
|
+
raise typer.Exit(1) from exc
|
|
261
|
+
|
|
262
|
+
console.print(
|
|
263
|
+
f"\n[dim]Retrieved {len(context.results)} results "
|
|
264
|
+
f"({context.total_tokens} tokens) in {context.elapsed_seconds:.3f}s[/dim]\n"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
268
|
+
table.add_column("File", style="dim", max_width=40)
|
|
269
|
+
table.add_column("Symbol", style="bold")
|
|
270
|
+
table.add_column("Lines", justify="right")
|
|
271
|
+
table.add_column("Score", justify="right")
|
|
272
|
+
|
|
273
|
+
for r in context.results:
|
|
274
|
+
table.add_row(
|
|
275
|
+
r.file.rel_path,
|
|
276
|
+
r.symbol.name,
|
|
277
|
+
f"{r.symbol.line_start}-{r.symbol.line_end}",
|
|
278
|
+
f"{r.score:.4f}",
|
|
279
|
+
)
|
|
280
|
+
console.print(table)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# stats
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@app.command()
|
|
289
|
+
def stats(
|
|
290
|
+
repo: str = typer.Argument(..., help="Path to the indexed repository"),
|
|
291
|
+
) -> None:
|
|
292
|
+
"""Show index statistics (files, symbols, chunks, DB size)"""
|
|
293
|
+
from trelix.core.config import IndexConfig
|
|
294
|
+
from trelix.store.db import Database
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
config = IndexConfig(repo_path=str(Path(repo).resolve()))
|
|
298
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
299
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
300
|
+
raise typer.Exit(1) from exc
|
|
301
|
+
|
|
302
|
+
db_path = config.db_path_absolute
|
|
303
|
+
if not db_path.exists():
|
|
304
|
+
err_console.print(
|
|
305
|
+
f"[red]No index found at {db_path}[/red] — run `trelix index {repo}` first."
|
|
306
|
+
)
|
|
307
|
+
raise typer.Exit(1)
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
with Database(db_path) as db:
|
|
311
|
+
conn = db._conn
|
|
312
|
+
file_count = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0]
|
|
313
|
+
symbol_count = conn.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
|
|
314
|
+
chunk_count = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
|
315
|
+
db_size_bytes = db_path.stat().st_size
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
err_console.print(f"[red]Failed to read index:[/red] {exc}")
|
|
318
|
+
raise typer.Exit(1) from exc
|
|
319
|
+
|
|
320
|
+
db_size_kb = db_size_bytes / 1024
|
|
321
|
+
|
|
322
|
+
console.print(Panel(f"[bold cyan]Index Stats:[/bold cyan] {repo}", expand=False))
|
|
323
|
+
|
|
324
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
325
|
+
table.add_column("Metric", style="dim")
|
|
326
|
+
table.add_column("Value", justify="right")
|
|
327
|
+
table.add_row("Files indexed", str(file_count))
|
|
328
|
+
table.add_row("Symbols", str(symbol_count))
|
|
329
|
+
table.add_row("Chunks", str(chunk_count))
|
|
330
|
+
table.add_row("DB size", f"{db_size_kb:.1f} KB")
|
|
331
|
+
console.print(table)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# update-index
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@app.command("update-index")
|
|
340
|
+
def update_index(
|
|
341
|
+
repo: str = typer.Argument(..., help="Path to the indexed repository"),
|
|
342
|
+
file: str = typer.Argument(..., help="File to re-index (absolute or relative to repo)"),
|
|
343
|
+
provider: str = typer.Option("local", help="Embedding provider: local | openai | azure"),
|
|
344
|
+
) -> None:
|
|
345
|
+
"""Re-index a single file after editing"""
|
|
346
|
+
_setup_logging(False)
|
|
347
|
+
|
|
348
|
+
from trelix.core.config import EmbedderConfig, IndexConfig
|
|
349
|
+
from trelix.indexing.indexer import Indexer
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
config = IndexConfig(
|
|
353
|
+
repo_path=str(Path(repo).resolve()),
|
|
354
|
+
embedder=EmbedderConfig(provider=cast(_EmbedderProvider, provider)),
|
|
355
|
+
)
|
|
356
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
357
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
358
|
+
raise typer.Exit(1) from exc
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
indexer = Indexer(config)
|
|
362
|
+
result = indexer.index_file(file)
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
err_console.print(f"[red]update-index failed:[/red] {exc}")
|
|
365
|
+
raise typer.Exit(1) from exc
|
|
366
|
+
|
|
367
|
+
print(json.dumps(result))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# migrate-vectors
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@app.command("migrate-vectors")
|
|
376
|
+
def migrate_vectors(
|
|
377
|
+
repo: str = typer.Argument(..., help="Path to the indexed repository"),
|
|
378
|
+
to: str = typer.Option("qdrant", help="Target backend: qdrant"),
|
|
379
|
+
url: str = typer.Option("http://localhost:6333", help="Qdrant URL"),
|
|
380
|
+
collection: str = typer.Option("trelix", help="Qdrant collection name"),
|
|
381
|
+
api_key: str = typer.Option("", help="Qdrant API key (optional)"),
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Migrate embeddings from SQLite to Qdrant (or another backend)."""
|
|
384
|
+
_setup_logging(False)
|
|
385
|
+
|
|
386
|
+
import sqlite3
|
|
387
|
+
import struct
|
|
388
|
+
|
|
389
|
+
from trelix.core.config import IndexConfig, StoreConfig
|
|
390
|
+
from trelix.store.vector_qdrant import QdrantVectorStore
|
|
391
|
+
|
|
392
|
+
if to != "qdrant":
|
|
393
|
+
err_console.print(
|
|
394
|
+
f"[red]Unsupported target backend:[/red] {to!r}. Only 'qdrant' is supported."
|
|
395
|
+
)
|
|
396
|
+
raise typer.Exit(1)
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
# Build config pointing at the existing SQLite index
|
|
400
|
+
config = IndexConfig(repo_path=str(Path(repo).resolve()))
|
|
401
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
402
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
403
|
+
raise typer.Exit(1) from exc
|
|
404
|
+
|
|
405
|
+
db_path = config.db_path_absolute
|
|
406
|
+
if not db_path.exists():
|
|
407
|
+
err_console.print(
|
|
408
|
+
f"[red]No index found at {db_path}[/red] — run `trelix index {repo}` first."
|
|
409
|
+
)
|
|
410
|
+
raise typer.Exit(1)
|
|
411
|
+
|
|
412
|
+
# Connect to the SQLite vector store directly to read raw embeddings
|
|
413
|
+
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
414
|
+
try:
|
|
415
|
+
conn.enable_load_extension(True)
|
|
416
|
+
import sqlite_vec
|
|
417
|
+
|
|
418
|
+
sqlite_vec.load(conn)
|
|
419
|
+
conn.enable_load_extension(False)
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
err_console.print(f"[red]Failed to load sqlite-vec:[/red] {exc}")
|
|
422
|
+
raise typer.Exit(1) from exc
|
|
423
|
+
|
|
424
|
+
# Detect embedding dimension from the sqlite-vec virtual table metadata
|
|
425
|
+
try:
|
|
426
|
+
row = conn.execute("SELECT embedding FROM chunk_embeddings LIMIT 1").fetchone()
|
|
427
|
+
except Exception as exc:
|
|
428
|
+
err_console.print(f"[red]Failed to read chunk_embeddings:[/red] {exc}")
|
|
429
|
+
raise typer.Exit(1) from exc
|
|
430
|
+
|
|
431
|
+
if row is None:
|
|
432
|
+
console.print(
|
|
433
|
+
"[yellow]No embeddings found in the SQLite store — nothing to migrate.[/yellow]"
|
|
434
|
+
)
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
raw_bytes: bytes = row[0]
|
|
438
|
+
dimension = len(raw_bytes) // 4 # float32 = 4 bytes
|
|
439
|
+
|
|
440
|
+
# Build a temporary StoreConfig pointing at Qdrant
|
|
441
|
+
qdrant_config = IndexConfig(
|
|
442
|
+
repo_path=config.repo_path,
|
|
443
|
+
store=StoreConfig( # type: ignore[call-arg]
|
|
444
|
+
db_path=config.store.db_path,
|
|
445
|
+
qdrant_url=url,
|
|
446
|
+
qdrant_api_key=api_key or None,
|
|
447
|
+
qdrant_collection=collection,
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
qdrant_store = QdrantVectorStore(qdrant_config, dimension)
|
|
451
|
+
|
|
452
|
+
# Stream all rows from sqlite-vec in batches
|
|
453
|
+
total_row = conn.execute("SELECT COUNT(*) FROM chunk_embeddings").fetchone()
|
|
454
|
+
total = total_row[0] if total_row else 0
|
|
455
|
+
console.print(f"[cyan]Migrating {total:,} embeddings (dim={dimension}) → Qdrant {url}[/cyan]")
|
|
456
|
+
|
|
457
|
+
BATCH = 500
|
|
458
|
+
offset = 0
|
|
459
|
+
migrated = 0
|
|
460
|
+
|
|
461
|
+
with Progress(
|
|
462
|
+
SpinnerColumn(),
|
|
463
|
+
TextColumn("[progress.description]{task.description}"),
|
|
464
|
+
BarColumn(),
|
|
465
|
+
TaskProgressColumn(),
|
|
466
|
+
console=console,
|
|
467
|
+
) as progress:
|
|
468
|
+
task = progress.add_task("Migrating…", total=total)
|
|
469
|
+
|
|
470
|
+
while True:
|
|
471
|
+
rows = conn.execute(
|
|
472
|
+
"SELECT chunk_id, embedding FROM chunk_embeddings LIMIT ? OFFSET ?",
|
|
473
|
+
(BATCH, offset),
|
|
474
|
+
).fetchall()
|
|
475
|
+
if not rows:
|
|
476
|
+
break
|
|
477
|
+
|
|
478
|
+
pairs: list[tuple[int, list[float]]] = []
|
|
479
|
+
for chunk_id, raw in rows:
|
|
480
|
+
n = len(raw) // 4
|
|
481
|
+
emb = list(struct.unpack(f"{n}f", raw))
|
|
482
|
+
pairs.append((chunk_id, emb))
|
|
483
|
+
|
|
484
|
+
qdrant_store.upsert_batch(pairs)
|
|
485
|
+
migrated += len(pairs)
|
|
486
|
+
offset += BATCH
|
|
487
|
+
progress.advance(task, advance=len(pairs))
|
|
488
|
+
|
|
489
|
+
conn.close()
|
|
490
|
+
console.print(f"[green]Migration complete:[/green] {migrated:,} embeddings written to Qdrant.")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
# watch
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@app.command()
|
|
499
|
+
def watch(
|
|
500
|
+
repo: str = typer.Argument(..., help="Path to the repository to watch"),
|
|
501
|
+
provider: str = typer.Option("local", help="Embedding provider: local | openai | azure"),
|
|
502
|
+
) -> None:
|
|
503
|
+
"""Watch repo for changes and auto-update index. Ctrl+C to stop."""
|
|
504
|
+
_setup_logging(False)
|
|
505
|
+
|
|
506
|
+
from trelix.core.config import EmbedderConfig, IndexConfig
|
|
507
|
+
from trelix.indexing.indexer import Indexer
|
|
508
|
+
from trelix.indexing.watcher import FileWatcher
|
|
509
|
+
|
|
510
|
+
try:
|
|
511
|
+
config = IndexConfig(
|
|
512
|
+
repo_path=str(Path(repo).resolve()),
|
|
513
|
+
embedder=EmbedderConfig(provider=cast(_EmbedderProvider, provider)),
|
|
514
|
+
)
|
|
515
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
516
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
517
|
+
raise typer.Exit(1) from exc
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
indexer = Indexer(config)
|
|
521
|
+
except Exception as exc:
|
|
522
|
+
err_console.print(f"[red]Failed to initialize indexer:[/red] {exc}")
|
|
523
|
+
raise typer.Exit(1) from exc
|
|
524
|
+
|
|
525
|
+
# Run initial full index so the watcher starts from a known-good state
|
|
526
|
+
console.print(Panel(f"[bold cyan]Initial index[/bold cyan] {repo}", expand=False))
|
|
527
|
+
try:
|
|
528
|
+
indexer.index()
|
|
529
|
+
except Exception as exc:
|
|
530
|
+
err_console.print(f"[red]Initial indexing failed:[/red] {exc}")
|
|
531
|
+
raise typer.Exit(1) from exc
|
|
532
|
+
|
|
533
|
+
# Start the file watcher
|
|
534
|
+
try:
|
|
535
|
+
watcher = FileWatcher(indexer, indexer.walker)
|
|
536
|
+
except ImportError as exc:
|
|
537
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
538
|
+
raise typer.Exit(1) from exc
|
|
539
|
+
|
|
540
|
+
watcher.start()
|
|
541
|
+
console.print("[green]Watching for changes. Press Ctrl+C to stop.[/green]")
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
import time as _time
|
|
545
|
+
|
|
546
|
+
while True:
|
|
547
|
+
_time.sleep(1)
|
|
548
|
+
except KeyboardInterrupt:
|
|
549
|
+
pass
|
|
550
|
+
finally:
|
|
551
|
+
watcher.stop()
|
|
552
|
+
console.print("\n[dim]Watch stopped.[/dim]")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
# Entry point
|
|
557
|
+
# ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
if __name__ == "__main__":
|
|
560
|
+
app()
|
trelix/core/__init__.py
ADDED
|
File without changes
|