codegraph-voyage 0.1.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.
@@ -0,0 +1,8 @@
1
+ """codegraph-voyage — hybrid semantic retrieval sidecar for CodeGraph.
2
+
3
+ A self-contained CLI tool that builds symbol-level documents from a CodeGraph
4
+ index, generates voyage-code-4 embeddings, stores them in a sidecar SQLite DB,
5
+ and fuses lexical + vector retrieval with weighted reciprocal-rank fusion.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """python -m tools.codegraph_voyage entry point."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,691 @@
1
+ """CLI entry point for codegraph-voyage.
2
+
3
+ Usage:
4
+ codegraph-voyage index [options]
5
+ codegraph-voyage search <query> [options]
6
+ codegraph-voyage status [options]
7
+ codegraph-voyage explore <query> [options]
8
+
9
+ The API key is read from the VOYAGE_API_KEY environment variable only;
10
+ no CLI flag accepts a secret value.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import sys
19
+ import textwrap
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from . import __version__
24
+ from .document import (
25
+ build_documents_from_db,
26
+ build_document_for_node_id,
27
+ compute_content_hash,
28
+ )
29
+ from .explore import codegraph_explore
30
+ from .providers import (
31
+ EmbeddingProvider,
32
+ FakeEmbeddingProvider,
33
+ VoyageEmbeddingProvider,
34
+ create_provider,
35
+ )
36
+ from .ranking import (
37
+ RankingResult,
38
+ find_pinned_candidates,
39
+ hybrid_search,
40
+ )
41
+ from .sanitize import sanitize_content, is_sensitive_path
42
+ from .sidecar import SidecarDB, SidecarError
43
+
44
+ DEFAULT_CODEGRAPH_DIR = ".codegraph"
45
+ DEFAULT_SIDECAR_NAME = "codegraph-voyage.db"
46
+ DEFAULT_PROVIDER = "fake"
47
+ DEFAULT_MODEL = "voyage-code-4"
48
+ DEFAULT_DIMENSIONS = 512
49
+
50
+
51
+ def _resolve_project_root(path: str | Path | None = None) -> Path:
52
+ """Resolve project root — the directory containing .codegraph/."""
53
+ start = Path(path or os.getcwd()).resolve()
54
+ # Walk up looking for .codegraph/
55
+ d = start
56
+ while d != d.parent:
57
+ if (d / DEFAULT_CODEGRAPH_DIR).is_dir():
58
+ return d
59
+ d = d.parent
60
+ # If not found, use start
61
+ return start
62
+
63
+
64
+ def _codegraph_db_path(root: Path) -> Path:
65
+ return root / DEFAULT_CODEGRAPH_DIR / "codegraph.db"
66
+
67
+
68
+ def _sidecar_db_path(root: Path) -> Path:
69
+ return root / DEFAULT_CODEGRAPH_DIR / DEFAULT_SIDECAR_NAME
70
+
71
+
72
+ def _make_provider(args: argparse.Namespace) -> EmbeddingProvider:
73
+ """Create an embedding provider from CLI args.
74
+
75
+ The API key is read from the VOYAGE_API_KEY environment variable only;
76
+ no CLI flag accepts a secret value.
77
+ """
78
+ api_key = os.environ.get("VOYAGE_API_KEY", "")
79
+ if args.provider == "voyage" and not api_key:
80
+ raise ValueError(
81
+ "VOYAGE_API_KEY is required for voyage provider; "
82
+ "set the VOYAGE_API_KEY environment variable"
83
+ )
84
+ return create_provider(
85
+ args.provider,
86
+ api_key=api_key,
87
+ model=args.model,
88
+ dimensions=args.dimensions,
89
+ )
90
+
91
+
92
+ def _make_provider_or_report(args: argparse.Namespace) -> EmbeddingProvider | None:
93
+ """Build a provider and turn configuration failures into actionable CLI errors."""
94
+ try:
95
+ return _make_provider(args)
96
+ except ValueError as exc:
97
+ print(f"Error: {exc}", file=sys.stderr)
98
+ return None
99
+
100
+
101
+ def _sidecar_model_mismatch(
102
+ status: dict[str, Any], provider: EmbeddingProvider
103
+ ) -> str | None:
104
+ """Return an actionable message when stored vectors are incompatible."""
105
+ groups = status.get("model_groups", [])
106
+ if not groups:
107
+ return None
108
+ if any(
109
+ group[0] == provider.model_name and int(group[1]) == provider.dimensions
110
+ for group in groups
111
+ ):
112
+ return None
113
+ stored = ", ".join(f"{group[0]} dims={group[1]}" for group in groups)
114
+ return (
115
+ f"Sidecar contains {stored}, but selected provider is "
116
+ f"{provider.model_name} dims={provider.dimensions}; pass the matching "
117
+ "--provider/--model/--dimensions options or clear and rebuild the index"
118
+ )
119
+
120
+
121
+ def cmd_index(args: argparse.Namespace) -> int:
122
+ """Build documents from CodeGraph and store embeddings in sidecar."""
123
+ root = _resolve_project_root(args.project)
124
+ cg_db = _codegraph_db_path(root)
125
+ sidecar_db = _sidecar_db_path(root)
126
+
127
+ if not cg_db.is_file():
128
+ print(f"Error: CodeGraph DB not found at {cg_db}", file=sys.stderr)
129
+ print("Run `codegraph init` or `codegraph sync` first.", file=sys.stderr)
130
+ return 1
131
+
132
+ provider = _make_provider_or_report(args)
133
+ if provider is None:
134
+ return 2
135
+ print(f"Provider: {provider.model_name} (dimensions={provider.dimensions})")
136
+
137
+ # Build documents
138
+ print("Building documents from CodeGraph DB...")
139
+ docs = build_documents_from_db(
140
+ cg_db,
141
+ root,
142
+ include_source=not args.no_source,
143
+ max_source_lines=args.max_source_lines,
144
+ node_kinds=tuple(args.kind.split(",")) if args.kind else None,
145
+ file_filter=args.file_filter,
146
+ )
147
+ print(f" Total documents: {len(docs)}")
148
+
149
+ # Sanitization is mandatory before any remote call. There is deliberately
150
+ # no CLI bypass: exclusions are a security boundary, not a tuning option.
151
+ # Hash the exact sanitized payload used for embedding.
152
+ print("Applying path/content sanitization...")
153
+ for doc in docs:
154
+ doc["document"] = sanitize_content(
155
+ doc["document"], doc.get("file_path", "")
156
+ )
157
+ doc["content_hash"] = compute_content_hash(doc["document"])
158
+
159
+ # Open sidecar
160
+ sidecar = SidecarDB(sidecar_db)
161
+ sidecar.open()
162
+ try:
163
+ # Find changed nodes
164
+ print("Finding changed nodes for incremental indexing...")
165
+ changed = sidecar.find_changed_nodes(docs, provider)
166
+ print(f" Changed/new: {len(changed)} / {len(docs)}")
167
+
168
+ if not changed:
169
+ print("No changes to index.")
170
+ # Still remove stale records
171
+ current_ids = {d["node_id"] for d in docs}
172
+ removed = sidecar.remove_stale_records(current_ids, provider)
173
+ if removed:
174
+ print(f" Removed stale records: {removed}")
175
+ status = sidecar.get_status()
176
+ print(f" Total embeddings: {status.get('total_embeddings', 0)}")
177
+ return 0
178
+
179
+ # Generate embeddings
180
+ print(f"Generating embeddings for {len(changed)} documents...")
181
+ texts = [d["document"] for d in changed]
182
+ try:
183
+ embeddings = provider.embed_documents(texts, input_type="document")
184
+ except (RuntimeError, OSError, ValueError) as exc:
185
+ print(f"Embedding failed; sidecar left unchanged: {exc}", file=sys.stderr)
186
+ return 2
187
+ if len(embeddings) != len(changed):
188
+ print(
189
+ "Embedding failed; sidecar left unchanged: provider returned "
190
+ f"{len(embeddings)} vectors for {len(changed)} documents",
191
+ file=sys.stderr,
192
+ )
193
+ return 2
194
+ print(f" Generated {len(embeddings)} embeddings")
195
+
196
+ # Store embeddings
197
+ records = []
198
+ for doc, emb in zip(changed, embeddings):
199
+ records.append({
200
+ "node_id": doc["node_id"],
201
+ "content_hash": doc["content_hash"],
202
+ "embedding": emb,
203
+ "node_kind": doc["node_kind"],
204
+ "name": doc["name"],
205
+ "qualified_name": doc["qualified_name"],
206
+ "file_path": doc["file_path"],
207
+ "language": doc["language"],
208
+ "start_line": doc["start_line"],
209
+ "end_line": doc["end_line"],
210
+ "document_text": doc["document"],
211
+ })
212
+
213
+ try:
214
+ stored = sidecar.store_embeddings(records, provider)
215
+ except (SidecarError, ValueError, OSError) as exc:
216
+ print(f"Embedding store failed; sidecar left unchanged: {exc}", file=sys.stderr)
217
+ return 2
218
+ print(f" Stored embeddings: {stored}")
219
+
220
+ # Remove stale records
221
+ current_ids = {d["node_id"] for d in docs}
222
+ removed = sidecar.remove_stale_records(current_ids, provider)
223
+ if removed:
224
+ print(f" Removed stale records: {removed}")
225
+
226
+ status = sidecar.get_status()
227
+ print(f" Total embeddings: {status.get('total_embeddings', 0)}")
228
+ finally:
229
+ sidecar.close()
230
+
231
+ return 0
232
+
233
+
234
+ def cmd_search(args: argparse.Namespace) -> int:
235
+ """Hybrid semantic search (semantic_candidates)."""
236
+ root = _resolve_project_root(args.project)
237
+ cg_db = _codegraph_db_path(root)
238
+ sidecar_db = _sidecar_db_path(root)
239
+ query = args.query
240
+
241
+ if not cg_db.is_file():
242
+ print(f"Error: CodeGraph DB not found at {cg_db}", file=sys.stderr)
243
+ return 1
244
+ if not sidecar_db.is_file():
245
+ print(
246
+ f"Sidecar DB not found at {sidecar_db}. Run 'index' first.",
247
+ file=sys.stderr,
248
+ )
249
+ return 1
250
+
251
+ provider = _make_provider_or_report(args)
252
+ if provider is None:
253
+ return 2
254
+
255
+ # Build lexical candidates (all docs)
256
+ if not args.json:
257
+ print(f"Building documents for query: {query}")
258
+ docs = build_documents_from_db(
259
+ cg_db,
260
+ root,
261
+ include_source=not args.no_source,
262
+ max_source_lines=args.max_source_lines,
263
+ node_kinds=tuple(args.kind.split(",")) if args.kind else None,
264
+ file_filter=args.file_filter,
265
+ )
266
+
267
+ # Load sidecar embeddings
268
+ sidecar = SidecarDB(sidecar_db)
269
+ sidecar.open()
270
+ try:
271
+ mismatch = _sidecar_model_mismatch(sidecar.get_status(), provider)
272
+ if mismatch:
273
+ print(f"Error: {mismatch}", file=sys.stderr)
274
+ return 2
275
+ emb_candidates = sidecar.get_all_embeddings(provider)
276
+ finally:
277
+ sidecar.close()
278
+
279
+ # Match docs with embeddings
280
+ emb_map = {c["node_id"]: c for c in emb_candidates}
281
+ vector_candidates: list[dict[str, Any]] = []
282
+ for d in docs:
283
+ if d["node_id"] in emb_map:
284
+ vector_candidates.append({
285
+ "node_id": d["node_id"],
286
+ "name": d["name"],
287
+ "qualified_name": d["qualified_name"],
288
+ "file_path": d["file_path"],
289
+ "node_kind": d["node_kind"],
290
+ "language": d["language"],
291
+ "start_line": d["start_line"],
292
+ "end_line": d["end_line"],
293
+ "embedding": emb_map[d["node_id"]]["embedding"],
294
+ "document_text": d["document"],
295
+ })
296
+
297
+ # Lexical candidates
298
+ lexical_candidates: list[dict[str, Any]] = [
299
+ {
300
+ "node_id": d["node_id"],
301
+ "name": d["name"],
302
+ "qualified_name": d["qualified_name"],
303
+ "file_path": d["file_path"],
304
+ "node_kind": d["node_kind"],
305
+ "language": d["language"],
306
+ "start_line": d["start_line"],
307
+ "end_line": d["end_line"],
308
+ "document_text": d["document"],
309
+ }
310
+ for d in docs
311
+ ]
312
+
313
+ # Generate query embedding; network/API failure degrades to lexical-only.
314
+ if not args.json:
315
+ print("Generating query embedding...")
316
+ try:
317
+ query_vector = provider.embed_query(query, input_type="query")
318
+ except (RuntimeError, OSError, ValueError) as exc:
319
+ print(f"Vector lookup unavailable; continuing lexical-only: {exc}", file=sys.stderr)
320
+ query_vector = None
321
+
322
+ # Find pinned candidates
323
+ if not args.json:
324
+ print("Identifying pinned candidates...")
325
+ pinned = find_pinned_candidates(
326
+ query, lexical_candidates + vector_candidates
327
+ )
328
+ if not args.json:
329
+ print(f" Pinned: {len(pinned)}")
330
+
331
+ # Hybrid search
332
+ if not args.json:
333
+ print("Running hybrid search (weighted RRF)...")
334
+ results = hybrid_search(
335
+ query,
336
+ query_vector,
337
+ vector_candidates,
338
+ lexical_candidates,
339
+ pinned_candidates=pinned if pinned else None,
340
+ top_k=args.top_k,
341
+ lexical_weight=args.lexical_weight,
342
+ vector_weight=args.vector_weight,
343
+ rrf_k=args.rrf_k,
344
+ )
345
+
346
+ # Output
347
+ if args.json:
348
+ output = [r.to_dict() for r in results]
349
+ print(json.dumps(output, indent=2))
350
+ else:
351
+ print(f"\nTop {len(results)} results:\n")
352
+ for i, r in enumerate(results, start=1):
353
+ provenance = r.provenance or "fused"
354
+ print(
355
+ f" {i:2d}. [{r.node_kind:12s}] {r.name:40s} "
356
+ f"score={r.score:.4f} "
357
+ f"{'[PINNED]' if r.is_pinned else ''} "
358
+ f"provenance={provenance}"
359
+ )
360
+ if r.qualified_name:
361
+ print(f" Qualified: {r.qualified_name}")
362
+ print(f" File: {r.file_path}:{r.start_line or ''}")
363
+ print()
364
+
365
+ return 0
366
+
367
+
368
+ def cmd_status(args: argparse.Namespace) -> int:
369
+ """Show sidecar status."""
370
+ root = _resolve_project_root(args.project)
371
+ cg_db = _codegraph_db_path(root)
372
+ sidecar_db = _sidecar_db_path(root)
373
+
374
+ print(f"Project root: {root}")
375
+ print(f"CodeGraph DB: {cg_db}")
376
+ print(f"Sidecar DB: {sidecar_db}")
377
+
378
+ # CodeGraph status
379
+ if cg_db.is_file():
380
+ import sqlite3
381
+ conn = sqlite3.connect(f"file://{cg_db.resolve()}?mode=ro", uri=True)
382
+ try:
383
+ total_nodes = conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
384
+ total_files = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0]
385
+ total_edges = conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
386
+ print(f"\nCodeGraph:")
387
+ print(f" Nodes: {total_nodes}")
388
+ print(f" Files: {total_files}")
389
+ print(f" Edges: {total_edges}")
390
+ finally:
391
+ conn.close()
392
+ else:
393
+ print(f"\nCodeGraph: NOT FOUND")
394
+
395
+ # Sidecar status
396
+ if sidecar_db.is_file():
397
+ sidecar = SidecarDB(sidecar_db)
398
+ sidecar.open()
399
+ try:
400
+ status = sidecar.get_status()
401
+ print(f"\nSidecar:")
402
+ print(f" Connected: {status.get('connected')}")
403
+ print(f" Total embeddings: {status.get('total_embeddings', 0)}")
404
+ for group in status.get("model_groups", []):
405
+ print(f" Group: model={group[0]} dims={group[1]} dtype={group[2]} count={group[3]}")
406
+ print(f" Schema version: {status.get('schema_version')}")
407
+ finally:
408
+ sidecar.close()
409
+ else:
410
+ print(f"\nSidecar: NOT FOUND (run 'index' first)")
411
+
412
+ return 0
413
+
414
+
415
+ def cmd_explore(args: argparse.Namespace) -> int:
416
+ """Integration: hybrid search then codegraph explore with candidates."""
417
+ root = _resolve_project_root(args.project)
418
+ cg_db = _codegraph_db_path(root)
419
+ sidecar_db = _sidecar_db_path(root)
420
+ query = args.query
421
+
422
+ if not cg_db.is_file():
423
+ print(f"Error: CodeGraph DB not found at {cg_db}", file=sys.stderr)
424
+ return 1
425
+
426
+ provider = _make_provider_or_report(args)
427
+ if provider is None:
428
+ return 2
429
+ sidecar_available = sidecar_db.is_file()
430
+
431
+ # Build docs
432
+ docs = build_documents_from_db(
433
+ cg_db, root,
434
+ include_source=not args.no_source,
435
+ max_source_lines=args.max_source_lines,
436
+ )
437
+
438
+ lexical_candidates: list[dict[str, Any]] = [
439
+ {
440
+ "node_id": d["node_id"],
441
+ "name": d["name"],
442
+ "qualified_name": d["qualified_name"],
443
+ "file_path": d["file_path"],
444
+ "node_kind": d["node_kind"],
445
+ "language": d["language"],
446
+ "start_line": d["start_line"],
447
+ "end_line": d["end_line"],
448
+ "document_text": d["document"],
449
+ }
450
+ for d in docs
451
+ ]
452
+
453
+ # Query embedding
454
+ query_vector = None
455
+ if sidecar_available:
456
+ sidecar = SidecarDB(sidecar_db)
457
+ sidecar.open()
458
+ try:
459
+ mismatch = _sidecar_model_mismatch(sidecar.get_status(), provider)
460
+ if mismatch:
461
+ print(f"Error: {mismatch}", file=sys.stderr)
462
+ return 2
463
+ emb_candidates = sidecar.get_all_embeddings(provider)
464
+ emb_map = {c["node_id"]: c for c in emb_candidates}
465
+ vector_candidates = [
466
+ {**d, "embedding": emb_map[d["node_id"]]["embedding"]}
467
+ for d in docs if d["node_id"] in emb_map
468
+ ]
469
+ try:
470
+ query_vector = provider.embed_query(query, input_type="query")
471
+ except (RuntimeError, OSError, ValueError) as exc:
472
+ print(f"Vector lookup unavailable; continuing lexical-only: {exc}", file=sys.stderr)
473
+ query_vector = None
474
+ finally:
475
+ sidecar.close()
476
+ else:
477
+ vector_candidates = []
478
+
479
+ # Pinned
480
+ pinned = find_pinned_candidates(query, lexical_candidates + vector_candidates)
481
+
482
+ # Hybrid search
483
+ results = hybrid_search(
484
+ query,
485
+ query_vector,
486
+ vector_candidates,
487
+ lexical_candidates,
488
+ pinned_candidates=pinned if pinned else None,
489
+ top_k=args.top_k,
490
+ lexical_weight=args.lexical_weight,
491
+ vector_weight=args.vector_weight,
492
+ rrf_k=args.rrf_k,
493
+ )
494
+
495
+ if args.dry_run:
496
+ from .explore import build_explore_query
497
+ q = build_explore_query(results, max_symbols=20)
498
+ print(f"Explore query ({len(results)} candidates → {len(q.split())} symbols):")
499
+ print(f" {q}")
500
+ return 0
501
+
502
+ # Run codegraph explore
503
+ result = codegraph_explore(
504
+ results,
505
+ project_path=root,
506
+ max_files=args.max_files,
507
+ codegraph_bin=args.codegraph_bin,
508
+ timeout=args.timeout,
509
+ )
510
+
511
+ if result.get("error"):
512
+ print(f"codegraph explore error: {result['error']}", file=sys.stderr)
513
+ return 1
514
+
515
+ print(result.get("stdout", ""))
516
+ if result.get("stderr"):
517
+ print(result["stderr"], file=sys.stderr)
518
+
519
+ return result.get("returncode", 0)
520
+
521
+
522
+ def _build_parser() -> argparse.ArgumentParser:
523
+ ap = argparse.ArgumentParser(
524
+ description="codegraph-voyage: hybrid semantic retrieval sidecar for CodeGraph",
525
+ formatter_class=argparse.RawDescriptionHelpFormatter,
526
+ epilog=textwrap.dedent("""\
527
+ Examples:
528
+ # Index with fake provider (no API key needed)
529
+ codegraph-voyage index
530
+
531
+ # Index with voyage-code-4
532
+ VOYAGE_API_KEY=... codegraph-voyage index --provider voyage
533
+
534
+ # Search (hybrid lexical + vector)
535
+ codegraph-voyage search "AuthService" --top-k 10
536
+
537
+ # Explore with semantic candidates
538
+ codegraph-voyage explore "UserManager" --max-files 8
539
+
540
+ # Status
541
+ codegraph-voyage status
542
+ """),
543
+ )
544
+ ap.add_argument("--version", action="version", version=f"codegraph-voyage {__version__}")
545
+
546
+ # Common options
547
+ common = argparse.ArgumentParser(add_help=False)
548
+ common.add_argument(
549
+ "-p", "--project",
550
+ default=None,
551
+ help="Project root path (default: current dir, walks up for .codegraph/)",
552
+ )
553
+ common.add_argument(
554
+ "--provider",
555
+ default=DEFAULT_PROVIDER,
556
+ choices=["fake", "voyage"],
557
+ help=f"Embedding provider (default: {DEFAULT_PROVIDER})",
558
+ )
559
+ common.add_argument(
560
+ "--model",
561
+ default=DEFAULT_MODEL,
562
+ help=f"Embedding model name (default: {DEFAULT_MODEL})",
563
+ )
564
+ common.add_argument(
565
+ "--dimensions",
566
+ type=int,
567
+ default=DEFAULT_DIMENSIONS,
568
+ help=f"Embedding dimensions (default: {DEFAULT_DIMENSIONS})",
569
+ )
570
+
571
+ sub = ap.add_subparsers(dest="command", required=True)
572
+
573
+ # index
574
+ p_index = sub.add_parser("index", help="Build documents and store embeddings", parents=[common])
575
+ p_index.add_argument(
576
+ "--no-source", action="store_true",
577
+ help="Exclude source lines from documents",
578
+ )
579
+ p_index.add_argument(
580
+ "--max-source-lines", type=int, default=200,
581
+ help="Max source lines per document (default: 200)",
582
+ )
583
+ p_index.add_argument(
584
+ "--kind", default=None,
585
+ help="Comma-separated node kinds to index (e.g. 'function,class')",
586
+ )
587
+ p_index.add_argument(
588
+ "--file-filter", default=None,
589
+ help="Optional file path filter (SQL LIKE pattern)",
590
+ )
591
+ p_index.set_defaults(func=cmd_index)
592
+
593
+ # search
594
+ p_search = sub.add_parser("search", aliases=["semantic_candidates"],
595
+ help="Hybrid semantic search", parents=[common])
596
+ p_search.add_argument("query", help="Search query")
597
+ p_search.add_argument(
598
+ "--top-k", type=int, default=20,
599
+ help="Max results (default: 20)",
600
+ )
601
+ p_search.add_argument(
602
+ "--json", action="store_true",
603
+ help="Output as JSON",
604
+ )
605
+ p_search.add_argument(
606
+ "--no-source", action="store_true",
607
+ )
608
+ p_search.add_argument(
609
+ "--max-source-lines", type=int, default=200,
610
+ )
611
+ p_search.add_argument(
612
+ "--kind", default=None,
613
+ help="Comma-separated node kinds to filter",
614
+ )
615
+ p_search.add_argument(
616
+ "--file-filter", default=None,
617
+ )
618
+ p_search.add_argument(
619
+ "--lexical-weight", type=float, default=0.5,
620
+ help="Weight for lexical RRF contribution (default: 0.5)",
621
+ )
622
+ p_search.add_argument(
623
+ "--vector-weight", type=float, default=0.5,
624
+ help="Weight for vector RRF contribution (default: 0.5)",
625
+ )
626
+ p_search.add_argument(
627
+ "--rrf-k", type=int, default=60,
628
+ help="RRF constant k (default: 60)",
629
+ )
630
+ p_search.set_defaults(func=cmd_search)
631
+
632
+ # status
633
+ p_status = sub.add_parser("status", help="Show sidecar status", parents=[common])
634
+ p_status.set_defaults(func=cmd_status)
635
+
636
+ # explore
637
+ p_explore = sub.add_parser("explore",
638
+ help="Hybrid search + codegraph explore",
639
+ parents=[common])
640
+ p_explore.add_argument("query", help="Search query")
641
+ p_explore.add_argument(
642
+ "--top-k", type=int, default=15,
643
+ help="Max candidates to pass to explore (default: 15)",
644
+ )
645
+ p_explore.add_argument(
646
+ "--max-files", type=int, default=12,
647
+ help="Max files for codegraph explore (default: 12)",
648
+ )
649
+ p_explore.add_argument(
650
+ "--codegraph-bin", default="codegraph",
651
+ help="Path to codegraph binary (default: codegraph)",
652
+ )
653
+ p_explore.add_argument(
654
+ "--timeout", type=int, default=60,
655
+ help="Timeout for codegraph explore (default: 60s)",
656
+ )
657
+ p_explore.add_argument(
658
+ "--dry-run", action="store_true",
659
+ help="Show the explore query without running it",
660
+ )
661
+ p_explore.add_argument(
662
+ "--no-source", action="store_true",
663
+ )
664
+ p_explore.add_argument(
665
+ "--max-source-lines", type=int, default=200,
666
+ )
667
+ p_explore.add_argument(
668
+ "--lexical-weight", type=float, default=0.5,
669
+ )
670
+ p_explore.add_argument(
671
+ "--vector-weight", type=float, default=0.5,
672
+ )
673
+ p_explore.add_argument(
674
+ "--rrf-k", type=int, default=60,
675
+ )
676
+ p_explore.set_defaults(func=cmd_explore)
677
+
678
+ return ap
679
+
680
+
681
+ def main(argv: list[str] | None = None) -> int:
682
+ ap = _build_parser()
683
+ args = ap.parse_args(argv)
684
+ if hasattr(args, "func"):
685
+ return args.func(args)
686
+ ap.print_help()
687
+ return 1
688
+
689
+
690
+ if __name__ == "__main__":
691
+ raise SystemExit(main())