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.
- java_codebase_rag/cli.py +14 -1
- java_codebase_rag/install_data/agents/explorer-rag-cli.md +65 -208
- java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +78 -232
- java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +44 -83
- java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +67 -135
- java_codebase_rag/installer.py +310 -32
- java_codebase_rag/jrag.py +112 -7
- java_codebase_rag/jrag_envelope.py +1 -1
- java_codebase_rag/jrag_render.py +12 -3
- java_codebase_rag/lance_optimize.py +18 -0
- java_codebase_rag/pipeline.py +14 -0
- {java_codebase_rag-0.8.0.dist-info → java_codebase_rag-0.9.0.dist-info}/METADATA +2 -2
- {java_codebase_rag-0.8.0.dist-info → java_codebase_rag-0.9.0.dist-info}/RECORD +20 -20
- mcp_v2.py +82 -26
- search_lancedb.py +149 -3
- server.py +11 -0
- {java_codebase_rag-0.8.0.dist-info → java_codebase_rag-0.9.0.dist-info}/WHEEL +0 -0
- {java_codebase_rag-0.8.0.dist-info → java_codebase_rag-0.9.0.dist-info}/entry_points.txt +0 -0
- {java_codebase_rag-0.8.0.dist-info → java_codebase_rag-0.9.0.dist-info}/licenses/LICENSE +0 -0
- {java_codebase_rag-0.8.0.dist-info → java_codebase_rag-0.9.0.dist-info}/top_level.txt +0 -0
java_codebase_rag/installer.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
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 ``"
|
|
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
|
|
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
|
|
510
|
-
return "
|
|
546
|
+
# Default to the recommended CLI surface when no flag is passed.
|
|
547
|
+
return "cli"
|
|
511
548
|
|
|
512
549
|
print(
|
|
513
|
-
"Note: '
|
|
514
|
-
"
|
|
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
|
-
" '
|
|
518
|
-
"(
|
|
554
|
+
" 'mcp' surface registers the java-codebase-rag MCP server "
|
|
555
|
+
"(5 tools: search/find/describe/neighbors/resolve)."
|
|
519
556
|
)
|
|
520
557
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
|
1123
|
+
from java_codebase_rag.config import resolve_operator_config
|
|
1110
1124
|
|
|
1111
1125
|
cfg = resolve_operator_config(
|
|
1112
|
-
source_root=
|
|
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
|
java_codebase_rag/jrag_render.py
CHANGED
|
@@ -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)
|