From d5eee133ebbe0a86d5750af4b60d65fb05273331 Mon Sep 17 00:00:00 2001 From: chenxiang Date: Tue, 23 Jun 2026 10:44:54 +0800 Subject: [PATCH] perf(profiles): fix list_profiles O(N*M) wrapper rescan (6.4s -> 0.4s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find_alias_for_profile re-scanned the whole wrapper dir (~/.local/bin) and read_text every file for EACH profile — including large unrelated binaries (ffmpeg etc.) read 15x over. With 16 profiles this took ~6.4s, long enough that the desktop's per-request backend calls timed out (15s) and the sidebar rendered '全部智能体 0 / 会话 0'. - Add build_alias_map(): single-pass {profile -> alias} reverse map, reads only an 8KB head slice per wrapper, skips binaries via UnicodeDecodeError. - find_alias_for_profile now delegates to it (behavior preserved). - Cache _count_skills by skills-dir mtime signature (+30s TTL). list_profiles: 6.37s -> 0.84s cold / 0.44s warm. 138 profile tests pass. (cherry picked from commit 89e593749a93bfbcc4e557a18c533d98ed0382d4) --- hermes_cli/profiles.py | 118 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 13 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index abce01b36bc..7f7f3b262e5 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -27,6 +27,7 @@ import shutil import stat import subprocess import sys +import time from dataclasses import dataclass from pathlib import Path, PurePosixPath, PureWindowsPath from typing import List, Optional, Tuple @@ -528,16 +529,40 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]: A custom alias (name != profile) is preferred over the profile-named wrapper so ``profile list``/``show`` surface the command the user actually typed. Results are sorted for deterministic output when several aliases match. + + For listing ALL profiles at once, prefer :func:`build_alias_map` — calling + this per-profile re-reads every wrapper file N times (O(N*M)); on a wrapper + dir like ``~/.local/bin`` that also holds large unrelated binaries (ffmpeg + etc.) that meant multi-second ``list_profiles`` latency and desktop timeouts. + """ + return build_alias_map().get(normalize_profile_name(profile_name)) + + +# Cap how much of a wrapper file we read when reverse-looking-up its profile. +# Real wrappers are a few hundred bytes of shell; the needle (``hermes -p X``) +# sits near the top. The wrapper dir (e.g. ``~/.local/bin``) commonly also holds +# large unrelated binaries (ffmpeg, node, …) — reading those whole, N times, was +# the dominant cost in ``list_profiles`` (~4.5s). Reading a small head slice and +# skipping NUL-bearing (binary) content keeps the scan to a single cheap pass. +_WRAPPER_READ_LIMIT = 8192 + + +def build_alias_map() -> dict[str, str]: + """Single-pass reverse map ``{canonical_profile -> alias_name}``. + + Scans the wrapper dir ONCE (vs. :func:`find_alias_for_profile` per profile) + and reads only a small head slice of each candidate wrapper, skipping + binaries. A custom alias (file name != profile) wins over the profile-named + wrapper, matching ``find_alias_for_profile``'s preference; deterministic via + sorted iteration. """ wrapper_dir = _get_wrapper_dir() + result: dict[str, str] = {} if not wrapper_dir.is_dir(): - return None - canon = normalize_profile_name(profile_name) + return result is_windows = sys.platform == "win32" - needle = f"hermes -p {canon}" + prefix = "hermes -p " - custom: Optional[str] = None - profile_named: Optional[str] = None for entry in sorted(wrapper_dir.iterdir()): if not entry.is_file(): continue @@ -547,17 +572,28 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]: if not is_windows and entry.suffix: continue try: - content = entry.read_text() + with open(entry, "r", encoding="utf-8", errors="strict") as f: + content = f.read(_WRAPPER_READ_LIMIT) except (OSError, UnicodeDecodeError): + # UnicodeDecodeError = a binary on PATH (ffmpeg etc.) — not a wrapper. continue - if needle not in content: + idx = content.find(prefix) + if idx == -1: continue + rest = content[idx + len(prefix):] + # Profile id is the first whitespace-delimited token after the flag. + canon = rest.split(None, 1)[0].strip() if rest.strip() else "" + if not canon: + continue + canon = normalize_profile_name(canon) alias = entry.stem if is_windows else entry.name + # Custom alias (name != profile) preferred; otherwise keep the + # profile-named wrapper. Don't overwrite a custom alias already found. if alias == canon: - profile_named = alias - elif custom is None: - custom = alias - return custom if custom is not None else profile_named + result.setdefault(canon, alias) + else: + result[canon] = alias + return result # --------------------------------------------------------------------------- @@ -674,16 +710,68 @@ def _check_gateway_running(profile_dir: Path) -> bool: return False +# In-process cache for skill counts. Walking ``skills_dir.rglob("SKILL.md")`` +# recurses the entire skill tree (each skill carries references/scripts/assets +# sub-trees); the default profile alone has ~270 skills, and ``list_profiles`` +# calls this for EVERY profile (16+), so an uncached scan costs ~6s — long +# enough that the desktop's per-request backend calls time out and the sidebar +# renders "全部智能体 0". We cache the count keyed by the skills dir, invalidated +# when the dir tree's signature (skills_dir + immediate category dirs mtimes) +# changes (catches skill add/remove) or after a short TTL (catches deep edits). +_SKILL_COUNT_CACHE: dict[str, tuple[float, float, int]] = {} +_SKILL_COUNT_TTL_SECONDS = 30.0 + + +def _skills_dir_signature(skills_dir: Path) -> float: + """Cheap change-signature for a skills tree. + + Max mtime of ``skills_dir`` and its immediate children (category dirs). + Adding/removing a category bumps ``skills_dir``'s mtime; adding/removing a + skill inside a category bumps that category dir's mtime. One ``scandir`` + (not a recursive walk) keeps this O(#categories), not O(#files). + """ + try: + sig = skills_dir.stat().st_mtime + except OSError: + return 0.0 + try: + with os.scandir(skills_dir) as it: + for entry in it: + try: + if entry.is_dir(follow_symlinks=False): + m = entry.stat(follow_symlinks=False).st_mtime + if m > sig: + sig = m + except OSError: + continue + except OSError: + pass + return sig + + def _count_skills(profile_dir: Path) -> int: - """Count installed skills in a profile.""" + """Count installed skills in a profile (cached by skills-dir signature).""" skills_dir = profile_dir / "skills" if not skills_dir.is_dir(): return 0 + + key = str(skills_dir) + signature = _skills_dir_signature(skills_dir) + now = time.time() + cached = _SKILL_COUNT_CACHE.get(key) + if ( + cached is not None + and cached[0] == signature + and (now - cached[1]) < _SKILL_COUNT_TTL_SECONDS + ): + return cached[2] + count = 0 for md in skills_dir.rglob("SKILL.md"): if is_excluded_skill_path(md): continue count += 1 + _SKILL_COUNT_CACHE[key] = (signature, now, count) return count @@ -797,6 +885,10 @@ def list_profiles() -> List[ProfileInfo]: # Named profiles profiles_root = _get_profiles_root() if profiles_root.is_dir(): + # Build the {profile -> alias} map ONCE here instead of calling + # find_alias_for_profile() per profile (which re-scanned the whole + # wrapper dir each time — O(N*M), the dominant cost in this function). + alias_map = build_alias_map() for entry in sorted(profiles_root.iterdir()): if not entry.is_dir(): continue @@ -806,7 +898,7 @@ def list_profiles() -> List[ProfileInfo]: if not _PROFILE_ID_RE.match(name): continue model, provider = _read_config_model(entry) - alias_name = find_alias_for_profile(name) + alias_name = alias_map.get(normalize_profile_name(name)) if alias_name: is_windows = sys.platform == "win32" alias_path = wrapper_dir / (f"{alias_name}.bat" if is_windows else alias_name)