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.
Files changed (60) hide show
  1. trelix/__init__.py +20 -0
  2. trelix/cli/__init__.py +1 -0
  3. trelix/cli/main.py +560 -0
  4. trelix/core/__init__.py +0 -0
  5. trelix/core/config.py +332 -0
  6. trelix/core/models.py +252 -0
  7. trelix/embedder/__init__.py +17 -0
  8. trelix/embedder/base.py +362 -0
  9. trelix/indexing/__init__.py +0 -0
  10. trelix/indexing/chunker.py +272 -0
  11. trelix/indexing/indexer.py +839 -0
  12. trelix/indexing/parser/__init__.py +0 -0
  13. trelix/indexing/parser/base.py +53 -0
  14. trelix/indexing/parser/extractors/__init__.py +0 -0
  15. trelix/indexing/parser/extractors/c.py +858 -0
  16. trelix/indexing/parser/extractors/cpp.py +800 -0
  17. trelix/indexing/parser/extractors/csharp.py +1174 -0
  18. trelix/indexing/parser/extractors/cshtml.py +238 -0
  19. trelix/indexing/parser/extractors/csproj.py +203 -0
  20. trelix/indexing/parser/extractors/css.py +511 -0
  21. trelix/indexing/parser/extractors/go.py +714 -0
  22. trelix/indexing/parser/extractors/html.py +661 -0
  23. trelix/indexing/parser/extractors/java.py +847 -0
  24. trelix/indexing/parser/extractors/javascript.py +783 -0
  25. trelix/indexing/parser/extractors/json_config.py +373 -0
  26. trelix/indexing/parser/extractors/kotlin.py +812 -0
  27. trelix/indexing/parser/extractors/markdown.py +276 -0
  28. trelix/indexing/parser/extractors/python.py +1084 -0
  29. trelix/indexing/parser/extractors/razor.py +502 -0
  30. trelix/indexing/parser/extractors/ruby.py +764 -0
  31. trelix/indexing/parser/extractors/rust.py +1037 -0
  32. trelix/indexing/parser/extractors/toml_config.py +384 -0
  33. trelix/indexing/parser/extractors/typescript.py +1279 -0
  34. trelix/indexing/parser/extractors/yaml_config.py +252 -0
  35. trelix/indexing/parser/registry.py +189 -0
  36. trelix/indexing/walker.py +171 -0
  37. trelix/indexing/watcher.py +302 -0
  38. trelix/retrieval/__init__.py +0 -0
  39. trelix/retrieval/assembler.py +207 -0
  40. trelix/retrieval/bm25.py +193 -0
  41. trelix/retrieval/fusion.py +61 -0
  42. trelix/retrieval/graph.py +364 -0
  43. trelix/retrieval/graph_rag.py +250 -0
  44. trelix/retrieval/grep_search.py +170 -0
  45. trelix/retrieval/planner/__init__.py +0 -0
  46. trelix/retrieval/planner/agent.py +475 -0
  47. trelix/retrieval/planner/models.py +226 -0
  48. trelix/retrieval/planner/prompts.py +193 -0
  49. trelix/retrieval/reranker.py +218 -0
  50. trelix/retrieval/retriever.py +633 -0
  51. trelix/retrieval/synthesizer.py +266 -0
  52. trelix/store/__init__.py +0 -0
  53. trelix/store/db.py +1220 -0
  54. trelix/store/vector.py +316 -0
  55. trelix/store/vector_qdrant.py +147 -0
  56. trelix-0.5.0.dist-info/METADATA +471 -0
  57. trelix-0.5.0.dist-info/RECORD +60 -0
  58. trelix-0.5.0.dist-info/WHEEL +4 -0
  59. trelix-0.5.0.dist-info/entry_points.txt +2 -0
  60. 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()
File without changes