java-codebase-rag 0.8.0__py3-none-any.whl → 0.9.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.
@@ -190,7 +190,27 @@ def prompt(
190
190
  if prompt_type == "checkbox":
191
191
  return questionary.checkbox(message, choices=choices, style=no_color_style).ask()
192
192
  elif prompt_type == "select":
193
- return questionary.select(message, choices=choices, style=no_color_style).ask()
193
+ # Normalize dict choices to questionary.Choice so ``default`` is
194
+ # matched by value. questionary.select forwards ``default`` as the
195
+ # cursor position (initial_choice), but its validation only matches
196
+ # ``default`` against ``Choice.value`` — a raw dict's value is
197
+ # invisible there, so passing ``default`` with dict choices raises.
198
+ # Plain-string choices pass through unchanged.
199
+ norm_choices = []
200
+ for c in choices or []:
201
+ if isinstance(c, dict):
202
+ title = c.get("name", c.get("value"))
203
+ norm_choices.append(
204
+ questionary.Choice(title, value=c.get("value", title))
205
+ )
206
+ else:
207
+ norm_choices.append(c)
208
+ return questionary.select(
209
+ message,
210
+ choices=norm_choices,
211
+ default=default,
212
+ style=no_color_style,
213
+ ).ask()
194
214
  elif prompt_type == "text":
195
215
  return questionary.text(message, default=default, style=no_color_style).ask()
196
216
  elif prompt_type == "confirm":
@@ -475,6 +495,21 @@ def select_scope(*, non_interactive: bool, cli_scope: str | None) -> Scope:
475
495
  return selected # type: ignore
476
496
 
477
497
 
498
+ def _surface_choices() -> list[dict]:
499
+ """Choice list for a surface select prompt.
500
+
501
+ Single source of truth for the surface option labels and order: ``cli`` is
502
+ listed first and marked "(Recommended)" — the jrag CLI surface is the
503
+ recommended default for new installs. ``mcp`` remains available. The
504
+ returned dicts are normalized to ``questionary.Choice`` inside ``prompt``
505
+ so a value-based ``default`` (cursor position) validates.
506
+ """
507
+ return [
508
+ {"name": "cli (Recommended)", "value": "cli"},
509
+ {"name": "mcp", "value": "mcp"},
510
+ ]
511
+
512
+
478
513
  def select_surface(
479
514
  *,
480
515
  non_interactive: bool,
@@ -483,15 +518,17 @@ def select_surface(
483
518
  ) -> Surface:
484
519
  """Select 'mcp' or 'cli' surface (PR-JRAG-5).
485
520
 
486
- The MCP surface registers the stdio MCP server (today's behavior). The CLI
487
- surface ships the ``jrag`` console-script skill+subagent instead — no MCP
488
- entry is registered.
521
+ The MCP surface registers the stdio MCP server. The CLI surface ships the
522
+ ``jrag`` console-script skill+subagent instead — no MCP entry is registered.
523
+ The CLI surface is the recommended default (listed first, marked
524
+ "(Recommended)").
489
525
 
490
526
  Args:
491
- non_interactive: If True, honor ``cli_surface`` (default ``"mcp"``).
527
+ non_interactive: If True, honor ``cli_surface`` (default ``"cli"``).
492
528
  cli_surface: Surface from the ``--surface`` CLI flag.
493
529
  prefill: On re-run, the surface recorded in the existing marker file.
494
- When set and the user does not pick otherwise, this is preserved.
530
+ When set, the cursor defaults to it so the user can keep the prior
531
+ choice with Enter (``cli`` is still shown first + recommended).
495
532
 
496
533
  Returns:
497
534
  Selected surface (``"mcp"`` or ``"cli"``).
@@ -506,25 +543,22 @@ def select_surface(
506
543
  return cli_surface # type: ignore
507
544
 
508
545
  if non_interactive:
509
- # Default to MCP for back-comat when no flag is passed.
510
- return "mcp"
546
+ # Default to the recommended CLI surface when no flag is passed.
547
+ return "cli"
511
548
 
512
549
  print(
513
- "Note: 'mcp' surface registers the java-codebase-rag MCP server (5 tools: "
514
- "search/find/describe/neighbors/resolve)."
550
+ "Note: 'cli' surface deploys the `jrag` console-script skill+subagent "
551
+ "(one command per intent, no MCP server) — recommended."
515
552
  )
516
553
  print(
517
- " 'cli' surface deploys the `jrag` console-script skill+subagent "
518
- "(one command per intent, no MCP server)."
554
+ " 'mcp' surface registers the java-codebase-rag MCP server "
555
+ "(5 tools: search/find/describe/neighbors/resolve)."
519
556
  )
520
557
 
521
- choices = ["mcp", "cli"]
522
- if prefill is not None:
523
- # Surface the prior choice first so the user can keep it with Enter.
524
- choices = [prefill] + [c for c in ("mcp", "cli") if c != prefill]
525
- default = prefill
526
- else:
527
- default = "mcp"
558
+ # cli is always shown first + recommended; the cursor defaults to the prior
559
+ # choice (prefill) on re-run so the user can keep it with Enter.
560
+ choices = _surface_choices()
561
+ default = prefill if prefill is not None else "cli"
528
562
 
529
563
  selected = prompt(
530
564
  "select",
@@ -1543,6 +1577,133 @@ def _refresh_mcp_config(
1543
1577
  return ArtifactResult(path=config_path, success=False, error=str(e))
1544
1578
 
1545
1579
 
1580
+ def _remove_mcp_entry(config_path: Path, *, dry_run: bool) -> ArtifactResult:
1581
+ """Remove the java-codebase-rag entry from an MCP config (surface migration).
1582
+
1583
+ Pops ONLY the ``java-codebase-rag`` key from ``mcpServers`` — other servers
1584
+ and the file itself are preserved. No-op success when the file or our key is
1585
+ absent. Atomic write (same tmp + ``os.replace`` pattern as ``merge_mcp_config``).
1586
+ Used by ``_undeploy_surface`` when switching off the MCP surface.
1587
+ """
1588
+ if not config_path.is_file():
1589
+ return ArtifactResult(path=config_path, success=True, error=None)
1590
+ try:
1591
+ with open(config_path, "r") as f:
1592
+ config = json.load(f)
1593
+ except (json.JSONDecodeError, IOError, OSError) as e:
1594
+ return ArtifactResult(
1595
+ path=config_path, success=False, error=f"Failed to parse {config_path}: {e}"
1596
+ )
1597
+
1598
+ servers = config.get("mcpServers")
1599
+ if not isinstance(servers, dict) or _MCP_SERVER_NAME not in servers:
1600
+ return ArtifactResult(path=config_path, success=True, error=None)
1601
+
1602
+ if dry_run:
1603
+ print(f"Would remove MCP entry from {config_path}")
1604
+ return ArtifactResult(path=config_path, success=True, error=None)
1605
+
1606
+ del servers[_MCP_SERVER_NAME]
1607
+ if not servers:
1608
+ config.pop("mcpServers", None)
1609
+
1610
+ tmp_name = None
1611
+ try:
1612
+ with tempfile.NamedTemporaryFile(
1613
+ mode="w",
1614
+ dir=config_path.parent,
1615
+ prefix=f".{config_path.name}.",
1616
+ delete=False,
1617
+ ) as tmp:
1618
+ json.dump(config, tmp, indent=2)
1619
+ tmp.flush()
1620
+ os.fsync(tmp.fileno())
1621
+ tmp_name = tmp.name
1622
+ os.replace(tmp_name, config_path)
1623
+ print(f"Removed MCP entry from {config_path}")
1624
+ return ArtifactResult(path=config_path, success=True, error=None)
1625
+ except (IOError, OSError) as e:
1626
+ if tmp_name:
1627
+ try:
1628
+ os.unlink(tmp_name)
1629
+ except OSError:
1630
+ pass
1631
+ return ArtifactResult(
1632
+ path=config_path, success=False, error=f"Failed to write {config_path}: {e}"
1633
+ )
1634
+
1635
+
1636
+ def _remove_artifact_file(dest_path: Path, *, dry_run: bool) -> ArtifactResult:
1637
+ """Remove a deployed skill/agent file (surface migration teardown).
1638
+
1639
+ Best-effort prunes the now-empty immediate parent dir (e.g.
1640
+ ``skills/explore-codebase``); leaves it in place if other files remain.
1641
+ No-op success when the file is absent.
1642
+ """
1643
+ if not dest_path.is_file():
1644
+ return ArtifactResult(path=dest_path, success=True, error=None)
1645
+ if dry_run:
1646
+ print(f"Would remove {dest_path}")
1647
+ return ArtifactResult(path=dest_path, success=True, error=None)
1648
+ try:
1649
+ dest_path.unlink()
1650
+ try:
1651
+ dest_path.parent.rmdir()
1652
+ except OSError:
1653
+ # Not empty or not removable — leave the directory in place.
1654
+ pass
1655
+ print(f"Removed {dest_path}")
1656
+ return ArtifactResult(path=dest_path, success=True, error=None)
1657
+ except OSError as e:
1658
+ return ArtifactResult(
1659
+ path=dest_path, success=False, error=f"Failed to remove {dest_path}: {e}"
1660
+ )
1661
+
1662
+
1663
+ def _undeploy_surface(
1664
+ host: HostConfig, scope: str, cwd: Path, *, surface: Surface, dry_run: bool
1665
+ ) -> list[ArtifactResult]:
1666
+ """Tear down every artifact a surface shipped (migration off ``surface``).
1667
+
1668
+ Iterates ``ARTIFACT_MANIFEST[surface]`` so adding/removing an artifact is
1669
+ one manifest edit, not two — mirrors ``deploy_artifacts``/``refresh_artifacts``.
1670
+ The ``mcp`` row removes just our server entry; ``skill``/``agent`` rows
1671
+ remove the file. Returns one ``ArtifactResult`` per manifest row.
1672
+ """
1673
+ results: list[ArtifactResult] = []
1674
+ for kind, _package_path, dest_relative in ARTIFACT_MANIFEST[surface]:
1675
+ if kind == "mcp":
1676
+ mcp_config_path = host.mcp_config_path(scope, cwd)
1677
+ results.append(_remove_mcp_entry(mcp_config_path, dry_run=dry_run))
1678
+ else:
1679
+ dest_path = host.scope_path(scope, cwd) / dest_relative
1680
+ results.append(_remove_artifact_file(dest_path, dry_run=dry_run))
1681
+ return results
1682
+
1683
+
1684
+ def _resolve_update_surface(*, surface: str | None, current: Surface) -> Surface | None:
1685
+ """Decide which surface ``run_update`` should target.
1686
+
1687
+ Returns a surface to migrate toward, or ``None`` when no global choice was
1688
+ made (non-TTY, no flag) — in which case ``run_update`` refreshes each host
1689
+ on its OWN recorded surface and migrates nothing.
1690
+
1691
+ - ``surface`` set (``--surface`` flag): validate and use it (enables
1692
+ migration; invalid value raises ``SystemExit(2)`` via ``select_surface``).
1693
+ - TTY, no flag: interactive prompt — ``cli`` recommended, cursor on the
1694
+ current surface so the user can keep it with Enter or switch.
1695
+ - non-TTY, no flag: ``None`` (no migration). Preserves the behavior of
1696
+ non-interactive ``run_update(...)`` callers and, crucially, leaves a
1697
+ mixed-surface marker untouched rather than normalizing it to the first
1698
+ host's surface.
1699
+ """
1700
+ if surface is not None:
1701
+ return select_surface(non_interactive=True, cli_surface=surface)
1702
+ if sys.stdin.isatty():
1703
+ return select_surface(non_interactive=False, cli_surface=None, prefill=current)
1704
+ return None
1705
+
1706
+
1546
1707
  def run_update(
1547
1708
  *,
1548
1709
  force: bool,
@@ -1550,6 +1711,7 @@ def run_update(
1550
1711
  cwd: Path | None = None,
1551
1712
  quiet: bool = False,
1552
1713
  verbose: bool = False,
1714
+ surface: str | None = None,
1553
1715
  ) -> int:
1554
1716
  """Run the update pipeline. Returns exit code.
1555
1717
 
@@ -1561,12 +1723,20 @@ def run_update(
1561
1723
  the indexing chatter that used to print to stdout moves onto the stderr
1562
1724
  renderer framing.
1563
1725
 
1726
+ Surface switching (mcp ↔ cli): a ``surface`` choice different from a host's
1727
+ recorded surface migrates that host — tearing down the old surface's
1728
+ artifacts (``_undeploy_surface``), deploying the new surface's
1729
+ (``deploy_artifacts``), and rewriting the marker so the switch persists.
1730
+
1564
1731
  Args:
1565
1732
  force: If True, overwrite all artifacts even if matching
1566
1733
  dry_run: If True, print changes without writing
1567
1734
  cwd: Current working directory (defaults to Path.cwd())
1568
1735
  quiet: If True, suppress progress output
1569
1736
  verbose: If True, raw-relay subprocess output (no Live region)
1737
+ surface: Target surface (``"mcp"``/``"cli"``) from ``--surface``. When
1738
+ ``None``: a TTY prompts (cursor on the current surface); a non-TTY
1739
+ keeps each host's recorded surface (no migration).
1570
1740
 
1571
1741
  Returns:
1572
1742
  Exit code (0=success, 1=partial, 2=fatal)
@@ -1585,19 +1755,115 @@ def run_update(
1585
1755
 
1586
1756
  print(f"Found {len(configured_hosts)} configured host(s).")
1587
1757
 
1588
- # Refresh artifacts for each host
1758
+ # Resolve the target surface. ``--surface`` validates + overrides; a TTY
1759
+ # with no flag prompts (cursor on the current surface); a non-TTY with no
1760
+ # flag yields None -> no global choice, so each host refreshes on its OWN
1761
+ # recorded surface and nothing migrates (preserves non-interactive callers
1762
+ # and leaves mixed-surface markers untouched rather than normalizing them).
1763
+ current_surfaces = {ch.surface for ch in configured_hosts}
1764
+ current_surface = configured_hosts[0].surface
1765
+ chosen_surface = _resolve_update_surface(
1766
+ surface=surface, current=current_surface
1767
+ )
1768
+ if len(current_surfaces) > 1:
1769
+ if chosen_surface is None:
1770
+ print(
1771
+ f"Note: configured hosts span multiple surfaces "
1772
+ f"({sorted(current_surfaces)}); refreshing each on its own "
1773
+ f"recorded surface (pass --surface to normalize)."
1774
+ )
1775
+ else:
1776
+ print(
1777
+ f"Note: configured hosts span multiple surfaces "
1778
+ f"({sorted(current_surfaces)}); normalizing to '{chosen_surface}'."
1779
+ )
1780
+
1781
+ # If any host needs to migrate, resolve the target surface's runtime binary
1782
+ # up front so a missing binary fails fast with a clear message rather than a
1783
+ # per-host partial. (For the cli surface ``deploy_artifacts`` ignores the
1784
+ # command, but resolving ``jrag`` confirms the invoked CLI actually exists.)
1785
+ # ``chosen_surface`` is guaranteed non-None here when migration_needed.
1786
+ migration_needed = (
1787
+ chosen_surface is not None
1788
+ and any(ch.surface != chosen_surface for ch in configured_hosts)
1789
+ )
1790
+ deploy_command = ""
1791
+ if migration_needed and not dry_run:
1792
+ try:
1793
+ deploy_command = resolve_mcp_command(
1794
+ non_interactive=not sys.stdin.isatty(), surface=chosen_surface
1795
+ )
1796
+ except SystemExit:
1797
+ binary = "jrag" if chosen_surface == "cli" else "java-codebase-rag-mcp"
1798
+ print(
1799
+ f"Error: `{binary}` not found on PATH — cannot migrate to the "
1800
+ f"'{chosen_surface}' surface."
1801
+ )
1802
+ print(
1803
+ "Ensure `java-codebase-rag` is installed, then re-run `update "
1804
+ f"--surface {chosen_surface}`."
1805
+ )
1806
+ return EXIT_PARTIAL
1807
+
1808
+ # Refresh (or migrate) artifacts for each host. When chosen_surface is None
1809
+ # (non-TTY, no flag) every host takes the refresh branch on its own surface.
1589
1810
  all_results = []
1590
- for host_config, scope, surface in configured_hosts:
1591
- print(f"\nRefreshing {host_config.name} ({scope} scope, surface={surface})...")
1592
- results = refresh_artifacts(
1593
- host_config,
1594
- scope,
1595
- cwd,
1596
- force=force,
1597
- dry_run=dry_run,
1598
- surface=surface,
1599
- )
1600
- all_results.extend(results)
1811
+ updated_configured: list[ConfiguredHost] = []
1812
+ migrated = False
1813
+ for host_config, scope, host_surface in configured_hosts:
1814
+ if chosen_surface is not None and host_surface != chosen_surface:
1815
+ migrated = True
1816
+ print(
1817
+ f"\nMigrating {host_config.name} ({scope} scope): "
1818
+ f"{host_surface} → {chosen_surface}..."
1819
+ )
1820
+ if dry_run:
1821
+ print(
1822
+ f" Would tear down {host_surface} artifacts and deploy "
1823
+ f"{chosen_surface} artifacts."
1824
+ )
1825
+ updated_configured.append(
1826
+ ConfiguredHost(host_config, scope, chosen_surface)
1827
+ )
1828
+ continue
1829
+ teardown_results = _undeploy_surface(
1830
+ host_config, scope, cwd, surface=host_surface, dry_run=False
1831
+ )
1832
+ all_results.extend(teardown_results)
1833
+ # deploy_command was resolved up front (migration_needed && not
1834
+ # dry_run); chosen_surface is non-None on this branch by the guard.
1835
+ deploy_results = deploy_artifacts(
1836
+ [host_config],
1837
+ scope,
1838
+ cwd,
1839
+ non_interactive=True,
1840
+ mcp_command=deploy_command,
1841
+ surface=chosen_surface,
1842
+ )
1843
+ all_results.extend(deploy_results)
1844
+ updated_configured.append(
1845
+ ConfiguredHost(host_config, scope, chosen_surface)
1846
+ )
1847
+ else:
1848
+ print(
1849
+ f"\nRefreshing {host_config.name} ({scope} scope, surface={host_surface})..."
1850
+ )
1851
+ results = refresh_artifacts(
1852
+ host_config,
1853
+ scope,
1854
+ cwd,
1855
+ force=force,
1856
+ dry_run=dry_run,
1857
+ surface=host_surface,
1858
+ )
1859
+ all_results.extend(results)
1860
+ updated_configured.append(
1861
+ ConfiguredHost(host_config, scope, host_surface)
1862
+ )
1863
+
1864
+ # Persist the surface switch so a later ``update`` sees the new surface.
1865
+ if migrated and not dry_run:
1866
+ _write_hosts_marker(cwd, updated_configured)
1601
1867
 
1602
1868
  # Check for partial failures
1603
1869
  partial_failures = [r for r in all_results if not r.success]
@@ -1799,7 +2065,19 @@ def run_install(
1799
2065
  return e.code
1800
2066
 
1801
2067
  # Stage 2: Embedding model
1802
- resolved_model = resolve_model(model, non_interactive=non_interactive)
2068
+ from java_codebase_rag.pipeline import vector_stack_installed
2069
+
2070
+ if not vector_stack_installed():
2071
+ # Graph-only install (macOS Intel): no torch/lancedb, so there is no vector
2072
+ # index to embed into — the embedding-model choice is inert here. Skip the
2073
+ # prompt and let init build the graph (vectors phase auto-skipped).
2074
+ print(
2075
+ "Skipping embedding model selection: vector stack not installed on this "
2076
+ "platform (graph-only mode)."
2077
+ )
2078
+ resolved_model = "auto"
2079
+ else:
2080
+ resolved_model = resolve_model(model, non_interactive=non_interactive)
1803
2081
 
1804
2082
  # Stage 3-4: Agent host + scope + surface selection
1805
2083
  prior_surface = _prior_surface_from_marker(cwd)
java_codebase_rag/jrag.py CHANGED
@@ -1058,6 +1058,9 @@ def build_parser() -> argparse.ArgumentParser:
1058
1058
  search.add_argument(
1059
1059
  "--hybrid", action="store_true", help="Enable vector+keyword hybrid search."
1060
1060
  )
1061
+ search.add_argument(
1062
+ "--explain", action="store_true", help="Show score breakdown per hit."
1063
+ )
1061
1064
  search.add_argument(
1062
1065
  "--path-contains", type=str, default=None, dest="path_contains",
1063
1066
  help="Narrow to chunks whose filename contains this substring.",
@@ -1088,6 +1091,11 @@ def build_parser() -> argparse.ArgumentParser:
1088
1091
  default=0,
1089
1092
  help="Page offset (passed to search_v2; paginated via +1-fetch).",
1090
1093
  )
1094
+ search.add_argument(
1095
+ "--chunks",
1096
+ action="store_true",
1097
+ help="Show every chunk (default collapses to one row per symbol/type).",
1098
+ )
1091
1099
  search.set_defaults(handler=_cmd_search, auto_scope=True)
1092
1100
 
1093
1101
  return parser
@@ -1096,20 +1104,26 @@ def build_parser() -> argparse.ArgumentParser:
1096
1104
  def _resolve_cfg(args: argparse.Namespace): # type: ignore[no-untyped-def]
1097
1105
  """Resolve operator config (reuses the operator's cocoindex-free resolver).
1098
1106
 
1099
- Same pattern as ``java_codebase_rag.cli._resolved_from_ns``: walks up from
1100
- cwd to find a project root (config file or ``.java-codebase-rag/`` index),
1101
- applies CLI ``--index-dir`` if given, and calls ``apply_to_os_environ`` so
1102
- downstream modules see a consistent env (critically: SBERT_MODEL for
1103
- ``jrag search`` in PR-JRAG-4).
1107
+ Mirrors ``java_codebase_rag.cli._resolved_from_ns``: pass ``source_root=None``
1108
+ so ``resolve_operator_config`` honors ``JAVA_CODEBASE_RAG_SOURCE_ROOT`` first,
1109
+ then a YAML ``source_root`` field, then walks up from cwd to find a project
1110
+ root. Passing a discovered root explicitly here would OVERRIDE a set env var
1111
+ whenever any ancestor dir has a ``.java-codebase-rag`` marker — silently
1112
+ ignoring the documented subprocess source-root mechanism that
1113
+ ``pipeline.subprocess_env`` sets for the cocoindex child (and that operators
1114
+ set directly).
1104
1115
 
1105
1116
  When the anchor is an index dir with no YAML beside it, resolution follows
1106
1117
  that index's ``config_source`` pointer (see ``config._effective_config_dir``)
1107
1118
  so a config living in a sibling dir is still found from inside a microservice.
1119
+ Applies CLI ``--index-dir`` if given and calls ``apply_to_os_environ`` so
1120
+ downstream modules see a consistent env (critically SBERT_MODEL for ``jrag
1121
+ search``).
1108
1122
  """
1109
- from java_codebase_rag.config import discover_project_root, resolve_operator_config
1123
+ from java_codebase_rag.config import resolve_operator_config
1110
1124
 
1111
1125
  cfg = resolve_operator_config(
1112
- source_root=discover_project_root(Path.cwd()),
1126
+ source_root=None,
1113
1127
  cli_index_dir=getattr(args, "index_dir", None),
1114
1128
  )
1115
1129
  cfg.apply_to_os_environ()
@@ -4019,6 +4033,65 @@ def _cmd_overview(args: argparse.Namespace) -> int:
4019
4033
  # ============================================================================
4020
4034
 
4021
4035
 
4036
+ def _zero_result_guidance(args: argparse.Namespace, graph) -> str | None:
4037
+ """Hint where matches live when a filtered search returns 0 results.
4038
+
4039
+ Runs ONE cheap unfiltered probe (limit 10) and tallies the filtered
4040
+ dimension across the probe hits, so an agent who filtered to e.g.
4041
+ ``--role SERVICE`` and got nothing learns the matches are under
4042
+ COMPONENT/OTHER instead of guessing. Returns None when no guidance
4043
+ applies: no recognizable single-dimension filter set, the probe is
4044
+ empty (truly no matches for this query), or the probe itself errored
4045
+ (non-fatal — the empty result still renders).
4046
+ """
4047
+ import mcp_v2
4048
+ from collections import Counter
4049
+
4050
+ from java_codebase_rag.jrag_envelope import normalize_enum
4051
+
4052
+ # Only the common single-dimension filters get guidance; first set wins.
4053
+ dims: list[tuple[str, str, str, str]] = []
4054
+ if args.role:
4055
+ dims.append(("role", "role", "roles", normalize_enum(args.role, kind="role")))
4056
+ if args.service:
4057
+ dims.append(("microservice", "service", "services", args.service))
4058
+ if args.module:
4059
+ dims.append(("module", "module", "modules", args.module))
4060
+ if not dims:
4061
+ return None
4062
+ attr, flag, plural, value = dims[0]
4063
+
4064
+ try:
4065
+ probe = mcp_v2.search_v2(
4066
+ args.query,
4067
+ table=args.table,
4068
+ hybrid=args.hybrid,
4069
+ limit=10,
4070
+ offset=0,
4071
+ path_contains=args.path_contains,
4072
+ filter=None,
4073
+ explain=False,
4074
+ graph=graph,
4075
+ )
4076
+ except Exception:
4077
+ return None
4078
+ if not probe.success or not probe.results:
4079
+ return None
4080
+
4081
+ counts: Counter = Counter(getattr(h, attr, None) for h in probe.results)
4082
+ counts.pop(None, None)
4083
+ if not counts:
4084
+ return None
4085
+ total = sum(counts.values())
4086
+ top = counts.most_common(3)
4087
+ alts = ", ".join(f"{v} ({c})" for v, c in top)
4088
+ suggestion = top[0][0]
4089
+ return (
4090
+ f"0 results with --{flag} {value}; {total} matches exist under other {plural}: "
4091
+ f"{alts} — try --{flag} {suggestion}"
4092
+ )
4093
+
4094
+
4022
4095
  def _cmd_search(args: argparse.Namespace) -> int:
4023
4096
  """search <query> — semantic search via search_v2 over Lance tables.
4024
4097
 
@@ -4048,6 +4121,19 @@ def _cmd_search(args: argparse.Namespace) -> int:
4048
4121
 
4049
4122
  limit = min(args.limit if args.limit is not None else 20, 499)
4050
4123
 
4124
+ # --limit 0: short-circuit to a clean empty page. mark_truncated(rows, 0)
4125
+ # would otherwise report truncated=True (a unit test pins the helper's
4126
+ # current behavior, so we fix this in the handler, not the helper), and
4127
+ # there is nothing to search — skip the embedding-model load entirely.
4128
+ if limit == 0:
4129
+ env = Envelope(
4130
+ status="ok", nodes={}, truncated=False,
4131
+ warnings=_auto_scope_notice(args),
4132
+ )
4133
+ next_actions_hook(env)
4134
+ print(render(env, fmt=args.format, detail=args.detail, noun="search"))
4135
+ return 0
4136
+
4051
4137
  # Build NodeFilter from flags (same set as `find` filter mode).
4052
4138
  filter_dict: dict = {}
4053
4139
  if args.service:
@@ -4101,7 +4187,9 @@ def _cmd_search(args: argparse.Namespace) -> int:
4101
4187
  offset=args.offset,
4102
4188
  path_contains=args.path_contains,
4103
4189
  filter=node_filter,
4190
+ explain=args.explain,
4104
4191
  graph=graph,
4192
+ dedup=not getattr(args, "chunks", False),
4105
4193
  )
4106
4194
 
4107
4195
  if not out.success:
@@ -4128,6 +4216,16 @@ def _cmd_search(args: argparse.Namespace) -> int:
4128
4216
  d["id"] = d.get("chunk_id") or d.get("symbol_id") or d.get("fqn") or ""
4129
4217
  if "kind" not in d:
4130
4218
  d["kind"] = "search_hit"
4219
+ # Add explain token when --explain is set
4220
+ if args.explain:
4221
+ from search_lancedb import explain_score_components
4222
+ comps = d.get("score_components")
4223
+ d["explain"] = explain_score_components(
4224
+ comps,
4225
+ role=d.get("role"),
4226
+ hybrid=bool(args.hybrid),
4227
+ graph_expanded=False,
4228
+ )
4131
4229
  hit_dicts.append(d)
4132
4230
 
4133
4231
  # --framework POST-filter: the graph stores `framework` only on Route nodes,
@@ -4156,6 +4254,13 @@ def _cmd_search(args: argparse.Namespace) -> int:
4156
4254
  f"--framework {framework_want!r} filtered out all {framework_dropped} hit(s); "
4157
4255
  f"no symbol's declaring type matched the framework's characteristic annotations"
4158
4256
  )
4257
+ # Zero-result guidance: when a structural filter emptied the page, run one
4258
+ # cheap unfiltered probe and point at where matches actually live (e.g.
4259
+ # "--role SERVICE" returned 0 but matches are under COMPONENT/OTHER).
4260
+ if not hit_dicts and filter_dict:
4261
+ guidance = _zero_result_guidance(args, graph)
4262
+ if guidance:
4263
+ warnings.append(guidance)
4159
4264
  env = Envelope(
4160
4265
  status="ok", nodes=nodes, truncated=truncated,
4161
4266
  warnings=warnings + _auto_scope_notice(args),
@@ -778,7 +778,7 @@ _BRIEF_NODE_KEYS: frozenset[str] = frozenset(
778
778
  # brief + location / classification / ranking. ``file`` is the composed
779
779
  # ``filename:start_line`` display field (see :func:`_compose_file`).
780
780
  _NORMAL_NODE_KEYS: frozenset[str] = _BRIEF_NODE_KEYS | frozenset(
781
- {"module", "role", "symbol_kind", "framework", "file", "score"}
781
+ {"module", "role", "symbol_kind", "framework", "file", "score", "explain", "chunks"}
782
782
  )
783
783
 
784
784
  # Edge attrs the text renderers read at the default level (target id variants
@@ -70,6 +70,8 @@ _NORMAL_INLINE_EXTRAS: tuple[str, ...] = (
70
70
  "framework",
71
71
  "file",
72
72
  "score",
73
+ "explain",
74
+ "chunks",
73
75
  )
74
76
 
75
77
  # Identity-adjacent extras shown inline at ``--detail brief``. ``score`` is the
@@ -87,6 +89,13 @@ _EDGE_LINE_KEYS: frozenset[str] = frozenset(
87
89
  )
88
90
 
89
91
 
92
+ def _format_inline_value(value: Any) -> str:
93
+ """Format a value for inline rendering: round floats to 3 decimals, others verbatim."""
94
+ if isinstance(value, float):
95
+ return f"{value:.3f}"
96
+ return str(value)
97
+
98
+
90
99
  def _next_action_lines(envelope: Envelope) -> list[str]:
91
100
  """Build up to 2 ``next: <hint>`` lines from ``agent_next_actions``.
92
101
 
@@ -266,7 +275,7 @@ def _render_listing(envelope: Envelope, *, noun: str, detail: str = "normal") ->
266
275
  # of every non-identity key (signature/annotations/snippet/...).
267
276
  if detail == "brief":
268
277
  extras = [
269
- f"{key}={node[key]}"
278
+ f"{key}={_format_inline_value(node[key])}"
270
279
  for key in _BRIEF_INLINE_EXTRAS
271
280
  if key in node and node[key] not in ("", None)
272
281
  ]
@@ -274,9 +283,9 @@ def _render_listing(envelope: Envelope, *, noun: str, detail: str = "normal") ->
274
283
  line += " " + " ".join(extras)
275
284
  elif detail == "normal":
276
285
  extras = [
277
- f"{key}={node[key]}"
286
+ f"{key}={_format_inline_value(node[key])}"
278
287
  for key in _NORMAL_INLINE_EXTRAS
279
- if key in node and node[key] not in ("", None)
288
+ if key in node and node[key] not in ("", None) and not (key == "chunks" and node[key] == 1)
280
289
  ]
281
290
  if extras:
282
291
  line += " " + " ".join(extras)