mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(gateway): let @ completion find folders by name
The fuzzy branch of `complete.path` ranked basenames from `_list_repo_files`, which lists files only, so a directory was only ever reachable by typing a `/` — `@Desktop` returned nothing at all. Rank each ancestor directory alongside the files, and break same-tier ties toward the folder so `@docs` leads with `docs/` rather than `docs.md`. Outside a git repo the fallback `os.walk` compounded this: it can spend the whole `_FUZZY_CACHE_MAX_FILES` budget inside one deep subtree before reaching a sibling, hiding top-level folders entirely. Seed the scan with a `listdir` of the root so immediate children are always candidates.
This commit is contained in:
parent
5646fed97e
commit
b378cc0a72
2 changed files with 120 additions and 14 deletions
|
|
@ -277,3 +277,79 @@ def test_fuzzy_paths_relative_to_cwd_inside_subdir(tmp_path, monkeypatch):
|
|||
readme_texts = [t for t, _, _ in _items("@README")]
|
||||
|
||||
assert not any("README.md" in t for t in readme_texts), readme_texts
|
||||
|
||||
|
||||
# ── Fuzzy DIRECTORY matching ─────────────────────────────────────────────
|
||||
# `@Desktop` used to return nothing: the fuzzy scanner ranks basenames from
|
||||
# `_list_repo_files`, which lists FILES only, so a directory whose name no
|
||||
# file inside it happens to match was unreachable without typing a `/`.
|
||||
|
||||
|
||||
def test_fuzzy_finds_directory_by_name(tmp_path, monkeypatch):
|
||||
"""A folder is reachable by bare name, with no trailing slash typed."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "Desktop" / "nested").mkdir(parents=True)
|
||||
# Deliberately named so NO file basename fuzzy-matches "Desktop".
|
||||
(tmp_path / "Desktop" / "nested" / "zzz.txt").write_text("x")
|
||||
|
||||
entries = _items("@Desktop")
|
||||
texts = [t for t, _, _ in entries]
|
||||
|
||||
assert "@folder:Desktop/" in texts, texts
|
||||
|
||||
row = next(r for r in entries if r[0] == "@folder:Desktop/")
|
||||
assert row[1] == "Desktop/"
|
||||
assert row[2] == "dir"
|
||||
|
||||
|
||||
def test_fuzzy_directory_prefix_match(tmp_path, monkeypatch):
|
||||
"""Partial folder names match too — `@Desk` finds `Desktop/`."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "Desktop").mkdir()
|
||||
(tmp_path / "Desktop" / "zzz.txt").write_text("x")
|
||||
|
||||
assert "@folder:Desktop/" in [t for t, _, _ in _items("@Desk")]
|
||||
|
||||
|
||||
def test_fuzzy_ranks_folder_above_file_at_same_tier(tmp_path, monkeypatch):
|
||||
"""At an equal match tier the folder leads: `@docs` means the directory."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "intro.md").write_text("x")
|
||||
(tmp_path / "docs.md").write_text("x")
|
||||
|
||||
texts = [t for t, _, _ in _items("@docs")]
|
||||
|
||||
assert texts[0] == "@folder:docs/", texts
|
||||
assert "@file:docs.md" in texts
|
||||
|
||||
|
||||
def test_fuzzy_hides_dot_directories_unless_asked(tmp_path, monkeypatch):
|
||||
"""Dot-folders follow the same rule as dotfiles."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".config").mkdir()
|
||||
(tmp_path / ".config" / "zzz.txt").write_text("x")
|
||||
|
||||
assert not any(".config" in t for t, _, _ in _items("@config"))
|
||||
assert any(t.startswith("@folder:.config") for t, _, _ in _items("@.config"))
|
||||
|
||||
|
||||
def test_fuzzy_finds_top_level_entries_outside_a_git_repo(tmp_path, monkeypatch):
|
||||
"""Outside a repo the fallback walk can exhaust its file budget on one
|
||||
deep subtree before reaching a sibling, hiding top-level folders. The
|
||||
root listdir seed guarantees immediate children are always candidates.
|
||||
"""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(server, "_FUZZY_CACHE_MAX_FILES", 5)
|
||||
|
||||
# A deep subtree that soaks up the entire (patched) file budget...
|
||||
deep = tmp_path / "aaa_hog"
|
||||
deep.mkdir()
|
||||
for i in range(40):
|
||||
(deep / f"f{i:03d}.txt").write_text("x")
|
||||
|
||||
# ...and the folder the user actually wants, sorted after it.
|
||||
(tmp_path / "Desktop").mkdir()
|
||||
(tmp_path / "Desktop" / "note.txt").write_text("x")
|
||||
|
||||
assert "@folder:Desktop/" in [t for t, _, _ in _items("@Desktop")]
|
||||
|
|
|
|||
|
|
@ -16184,24 +16184,54 @@ def _(rid, params: dict) -> dict:
|
|||
and "/" not in path_part
|
||||
and prefix_tag != "folder"
|
||||
):
|
||||
ranked: list[tuple[tuple[int, int], str, str]] = []
|
||||
for rel in _list_repo_files(root):
|
||||
basename = os.path.basename(rel)
|
||||
if basename.startswith(".") and not path_part.startswith("."):
|
||||
continue
|
||||
rank = _fuzzy_basename_rank(basename, path_part)
|
||||
if rank is None:
|
||||
continue
|
||||
ranked.append((rank, rel, basename))
|
||||
ranked: list[tuple[tuple[int, int], str, str, bool]] = []
|
||||
walked_dirs: set[str] = set()
|
||||
seen: set[str] = set()
|
||||
want_hidden = path_part.startswith(".")
|
||||
|
||||
ranked.sort(key=lambda r: (r[0], len(r[1]), r[1]))
|
||||
def _consider(rel: str, name: str, is_dir: bool) -> None:
|
||||
if rel in seen or (name.startswith(".") and not want_hidden):
|
||||
return
|
||||
rank = _fuzzy_basename_rank(name, path_part)
|
||||
if rank is not None:
|
||||
seen.add(rel)
|
||||
ranked.append((rank, rel, name, is_dir))
|
||||
|
||||
# Seed with root's immediate children. `_list_repo_files` is capped
|
||||
# at _FUZZY_CACHE_MAX_FILES, and outside a git repo the fallback
|
||||
# walk can burn that whole budget on one deep subtree before ever
|
||||
# reaching a sibling — which is why `@Desk` in a non-repo $HOME
|
||||
# found nothing. One listdir keeps the top level always reachable.
|
||||
try:
|
||||
for entry in os.listdir(root):
|
||||
if entry not in _FUZZY_FALLBACK_EXCLUDES:
|
||||
_consider(entry, entry, os.path.isdir(os.path.join(root, entry)))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for rel in _list_repo_files(root):
|
||||
_consider(rel, os.path.basename(rel), False)
|
||||
|
||||
# Directories are only implied by the file listing, so rank each
|
||||
# ancestor too. Without this a bare `@Desktop` finds nothing —
|
||||
# a folder with no name-matching file inside it is invisible to
|
||||
# a file-only scan, which is the "can't @ a folder by name" bug.
|
||||
parent = os.path.dirname(rel)
|
||||
while parent and parent not in walked_dirs:
|
||||
walked_dirs.add(parent)
|
||||
_consider(parent, os.path.basename(parent), True)
|
||||
parent = os.path.dirname(parent)
|
||||
|
||||
# Same rank tier: folders first, so `@Desktop` leads with the folder
|
||||
# rather than a file that merely fuzzy-matches the same letters.
|
||||
ranked.sort(key=lambda r: (r[0], not r[3], len(r[1]), r[1]))
|
||||
tag = prefix_tag or "file"
|
||||
for _, rel, basename in ranked[:30]:
|
||||
for _, rel, basename, is_dir in ranked[:30]:
|
||||
items.append(
|
||||
{
|
||||
"text": f"@{tag}:{rel}",
|
||||
"display": basename,
|
||||
"meta": os.path.dirname(rel),
|
||||
"text": f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}",
|
||||
"display": basename + ("/" if is_dir else ""),
|
||||
"meta": "dir" if is_dir else os.path.dirname(rel),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue