From 7532f2045f1836b25c7c0965b1235c10fadc55ab Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:12:13 -0700 Subject: [PATCH 1/7] =?UTF-8?q?refactor(tui=5Fgateway):=20methods=5Ftools/?= =?UTF-8?q?slash/complete=20=E2=80=94=20session+error=20guards,=20shared?= =?UTF-8?q?=20plugin=20runner,=20learning.*=20table,=20layout=20compaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tui_gateway/methods_complete.py | 200 ++++----- tui_gateway/methods_complete_helpers.py | 34 +- tui_gateway/methods_slash.py | 113 ++---- tui_gateway/methods_tools.py | 517 ++++++++---------------- 4 files changed, 296 insertions(+), 568 deletions(-) diff --git a/tui_gateway/methods_complete.py b/tui_gateway/methods_complete.py index 60f47469db..7f2869070c 100644 --- a/tui_gateway/methods_complete.py +++ b/tui_gateway/methods_complete.py @@ -18,14 +18,12 @@ _AT_DIRECTIVE_HINTS = [ ("@file:", "attach file"), ("@folder:", "attach folder"), ("@url:", "fetch url"), - ("@git:", "git log"), -] + ("@git:", "git log")] _SLASH_EXTRAS = [ ("/density", "Toggle compact display mode"), ("/details", "Control agent detail visibility"), ("/logs", "Show recent gateway log lines"), - ("/mouse", "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]"), -] + ("/mouse", "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]")] def _item(text: str, meta: str, display: str | None = None) -> dict: @@ -38,17 +36,13 @@ def _(rid, params: dict) -> dict: text = params.get("text", "") if not text: return _err(rid, 4004, "empty paste") - _paste_counter += 1 line_count = text.count("\n") + 1 paste_dir = _hermes_home / "pastes" paste_dir.mkdir(parents=True, exist_ok=True) - from datetime import datetime - paste_file = paste_dir / f"paste_{_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt" paste_file.write_text(text, encoding="utf-8") - placeholder = f"[Pasted text #{_paste_counter}: {line_count} lines \u2192 {paste_file}]" return _ok(rid, {"placeholder": placeholder, "path": str(paste_file), "lines": line_count}) @@ -60,7 +54,6 @@ def _profile_mention_items(prefix: str) -> list[dict]: out: list[dict] = [] try: from hermes_cli.profiles import list_profiles - seen: set[str] = set() for p in list_profiles(): name = (p.name or "").strip() @@ -82,12 +75,10 @@ def _plugin_reference_items(pfx: str, qval: str) -> list[dict] | None: no provider owns ``pfx`` or it fails.""" try: from agent.context_references import get_context_reference_providers - prov = get_context_reference_providers().get(pfx) if prov is None: return None import asyncio - coro = prov.autocomplete(qval, limit=20) try: loop = asyncio.get_running_loop() @@ -95,7 +86,6 @@ def _plugin_reference_items(pfx: str, qval: str) -> list[dict] | None: loop = None if loop and loop.is_running(): import concurrent.futures - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: ac = pool.submit(asyncio.run, coro).result() else: @@ -128,7 +118,6 @@ def _fuzzy_basename_items(root: str, path_part: str, prefix_tag: str) -> list[di _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) # Rank each ancestor dir too — a folder with no name-matching file inside is otherwise invisible. @@ -145,10 +134,8 @@ def _fuzzy_basename_items(root: str, path_part: str, prefix_tag: str) -> list[di _item( f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}", "dir" if is_dir else os.path.dirname(rel), - basename + ("/" if is_dir else ""), - ) - for _, rel, basename, is_dir in ranked[:30] - ] + basename + ("/" if is_dir else "")) + for _, rel, basename, is_dir in ranked[:30]] @method("complete.path") @@ -156,19 +143,16 @@ def _(rid, params: dict) -> dict: word = params.get("word", "") if not word: return _ok(rid, {"items": []}) - items: list[dict] = [] try: root = _completion_cwd(params) is_context = word.startswith("@") query = word[1:] if is_context else word - if is_context and not query: items = [_item(t, m) for t, m in _AT_DIRECTIVE_HINTS] items.extend(_profile_mention_items("")) # `@` alone reveals agent profiles too try: from agent.context_references import get_context_reference_providers - for _pfx, _prov in sorted(get_context_reference_providers().items()): items.append(_item(f"@{_pfx}:", _prov.description or f"plugin: {_pfx}")) except Exception: @@ -196,13 +180,11 @@ def _(rid, params: dict) -> dict: if is_context and path_part.startswith("/") and not path_part.startswith("//"): if not _abs_completion_prefix_exists(path_part): path_part = path_part.lstrip("/") - if is_context and path_part and len(path_part.strip()) >= 2 and "/" not in path_part and prefix_tag != "folder": items = _fuzzy_basename_items(root, path_part, prefix_tag) if not prefix_tag: # bare `@name` may be an agent mention: profiles rank ABOVE file hits items = _profile_mention_items(path_part) + items return _ok(rid, {"items": items}) - expanded = _normalize_completion_path(path_part) if path_part else "." if expanded == "." or not expanded: search_dir, match = ".", "" @@ -211,11 +193,9 @@ def _(rid, params: dict) -> dict: else: search_dir = os.path.dirname(expanded) or "." match = os.path.basename(expanded) - search_dir = search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir) if not os.path.isdir(search_dir): return _ok(rid, {"items": []}) - want_dir = prefix_tag == "folder" match_lower = match.lower() for entry in sorted(os.listdir(search_dir)): @@ -232,7 +212,6 @@ def _(rid, params: dict) -> dict: continue rel = os.path.relpath(full, root).replace(os.sep, "/") suffix = "/" if is_dir else "" - if is_context and prefix_tag: text = f"@{prefix_tag}:{rel}{suffix}" elif is_context: @@ -243,7 +222,6 @@ def _(rid, params: dict) -> dict: text = "./" + rel + suffix else: text = rel + suffix - items.append(_item(text, "dir" if is_dir else "", entry + suffix)) if len(items) >= 30: break @@ -256,7 +234,6 @@ def _(rid, params: dict) -> dict: items = _profile_mention_items(path_part) + items except Exception: pass - return _ok(rid, {"items": items}) @@ -265,15 +242,12 @@ def _(rid, params: dict) -> dict: text = params.get("text", "") if not text.startswith("/"): return _ok(rid, {"items": []}) - try: from hermes_cli.commands import SlashCommandCompleter from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import to_plain_text - from agent.skill_commands import get_skill_commands from agent.skill_bundles import get_skill_bundles - completer = SlashCommandCompleter( skill_commands_provider=lambda: get_skill_commands(), skill_bundles_provider=lambda: get_skill_bundles() ) @@ -291,9 +265,7 @@ def _(rid, params: dict) -> dict: "meta": to_plain_text(c.display_meta) if c.display_meta else "", "kind": "skill" if c.text.strip().lstrip("/").lower() in skill_names else "command", } - for c in completer.get_completions(doc, None) - ] - + for c in completer.get_completions(doc, None)] items = to_items(Document(text, len(text))) # Rank + bound while a `/token` is under the cursor (the one stage skills are @@ -304,120 +276,114 @@ def _(rid, params: dict) -> dict: # catalog entries whose name SUBSTRING or DESCRIPTION words match (name outranks description). if " " not in text and len(text) > 1: from tui_gateway.slash_fuzzy import fuzzy_rank_slash_items, normalize_slash_search_query - items, score_of = fuzzy_rank_slash_items( - items, to_items(Document("/", 1)), normalize_slash_search_query(text) - ) - + items, to_items(Document("/", 1)), normalize_slash_search_query(text)) usage, origin_of = _skill_usage_lookup() items = _rank_slash_completions(items, usage, origin_of, browsing=text == "/", score_of=score_of) else: items = items[:_SLASH_COMPLETION_LIMIT] - text_lower = text.lower() for extra_text, extra_meta in _SLASH_EXTRAS: if extra_text.startswith(text_lower) and not any(item["text"] == extra_text for item in items): items.append({"text": extra_text, "display": extra_text, "meta": extra_meta, "kind": "command"}) - details_items = _details_completions(text) if details_items is not None: return _ok(rid, {"items": details_items, "replace_from": text.rfind(" ") + 1 if " " in text else len(text)}) - return _ok(rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1}) except Exception as e: return _err(rid, 5020, str(e)) +def _catch(fail_code: int): + """Handler body exceptions → ``_err(rid, fail_code, str(e))``.""" + + def deco(body): + def handler(rid, params: dict) -> dict: + try: + return body(rid, params) + except Exception as e: + return _err(rid, fail_code, str(e)) + + handler.__doc__ = body.__doc__ + return handler + + return deco + + +def _session_agent(params: dict): + session = _sessions.get(params.get("session_id", "")) + return session.get("agent") if session else None + + @method("model.options") @_profile_scoped +@_catch(5033) def _(rid, params: dict) -> dict: - try: - from hermes_cli.inventory import build_model_options_payload - - session = _sessions.get(params.get("session_id", "")) - agent = session.get("agent") if session else None - # A spawned agent owns the live provider/model/base_url; empty attributes must - # NOT clobber disk config (with_overrides is truthy-only). - ctx = _model_picker_context(agent) - payload = build_model_options_payload( - ctx, - explicit_only=bool(params.get("explicit_only")), - include_unconfigured=bool(params.get("include_unconfigured")), - refresh=bool(params.get("refresh")), - ) - return _ok(rid, payload) - except Exception as e: - return _err(rid, 5033, str(e)) + from hermes_cli.inventory import build_model_options_payload + # A spawned agent owns the live provider/model/base_url; empty attributes must + # NOT clobber disk config (with_overrides is truthy-only). + ctx = _model_picker_context(_session_agent(params)) + payload = build_model_options_payload( + ctx, + explicit_only=bool(params.get("explicit_only")), + include_unconfigured=bool(params.get("include_unconfigured")), + refresh=bool(params.get("refresh"))) + return _ok(rid, payload) @method("model.save_key") +@_catch(5034) def _(rid, params: dict) -> dict: """Save an API key for ``slug``; return its refreshed provider row (model.options shape + ``authenticated``).""" - try: - from hermes_cli.auth import PROVIDER_REGISTRY - from hermes_cli.config import is_managed - from hermes_cli.inventory import build_models_payload - - slug = (params.get("slug") or "").strip() - api_key = (params.get("api_key") or "").strip() - if not slug or not api_key: - return _err(rid, 4001, "slug and api_key are required") - if is_managed(): - return _err(rid, 4006, "managed install — credentials are read-only") - pconfig = PROVIDER_REGISTRY.get(slug) - if not pconfig: - return _err(rid, 4002, f"unknown provider: {slug}") - if pconfig.auth_type != "api_key": - return _err(rid, 4003, f"{pconfig.name} uses {pconfig.auth_type} auth — run `hermes model` to configure") - if not pconfig.api_key_env_vars: - return _err(rid, 4004, f"no env var defined for {pconfig.name}") - - # Unified lifecycle rotates stale config.yaml mirrors of the old key too. - env_var = pconfig.api_key_env_vars[0] - from hermes_cli.credential_lifecycle import save_provider_env_credential - - save_provider_env_credential(env_var, api_key) - os.environ[env_var] = api_key # so the refreshed inventory sees it - - # Shared inventory builder (lock-step with model.options / dashboard); picker_hints carries `authenticated`. - session = _sessions.get(params.get("session_id", "")) - agent = session.get("agent") if session else None - payload = build_models_payload(_model_picker_context(agent), picker_hints=True, max_models=50) - provider_data = next((p for p in payload["providers"] if p["slug"] == slug), None) - if provider_data is None: # key saved but provider didn't appear — still success - provider_data = {"slug": slug, "name": pconfig.name, "is_current": False, "models": [], "total_models": 0} - provider_data["authenticated"] = True # synthetic fallback bypasses picker_hints - return _ok(rid, {"provider": provider_data}) - except Exception as e: - return _err(rid, 5034, str(e)) + from hermes_cli.auth import PROVIDER_REGISTRY + from hermes_cli.config import is_managed + from hermes_cli.inventory import build_models_payload + slug = (params.get("slug") or "").strip() + api_key = (params.get("api_key") or "").strip() + if not slug or not api_key: + return _err(rid, 4001, "slug and api_key are required") + if is_managed(): + return _err(rid, 4006, "managed install — credentials are read-only") + pconfig = PROVIDER_REGISTRY.get(slug) + if not pconfig: + return _err(rid, 4002, f"unknown provider: {slug}") + if pconfig.auth_type != "api_key": + return _err(rid, 4003, f"{pconfig.name} uses {pconfig.auth_type} auth — run `hermes model` to configure") + if not pconfig.api_key_env_vars: + return _err(rid, 4004, f"no env var defined for {pconfig.name}") + # Unified lifecycle rotates stale config.yaml mirrors of the old key too. + env_var = pconfig.api_key_env_vars[0] + from hermes_cli.credential_lifecycle import save_provider_env_credential + save_provider_env_credential(env_var, api_key) + os.environ[env_var] = api_key # so the refreshed inventory sees it + # Shared inventory builder (lock-step with model.options / dashboard); picker_hints carries `authenticated`. + payload = build_models_payload(_model_picker_context(_session_agent(params)), picker_hints=True, max_models=50) + provider_data = next((p for p in payload["providers"] if p["slug"] == slug), None) + if provider_data is None: # key saved but provider didn't appear — still success + provider_data = {"slug": slug, "name": pconfig.name, "is_current": False, "models": [], "total_models": 0} + provider_data["authenticated"] = True # synthetic fallback bypasses picker_hints + return _ok(rid, {"provider": provider_data}) @method("model.disconnect") +@_catch(5035) def _(rid, params: dict) -> dict: """Remove all credentials (env keys AND OAuth/pool state) for provider ``slug``.""" - try: - from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth - from hermes_cli.credential_lifecycle import remove_provider_env_credential - - slug = (params.get("slug") or "").strip() - if not slug: - return _err(rid, 4001, "slug is required") - pconfig = PROVIDER_REGISTRY.get(slug) - cleared_env = False - # Remove env vars plus every mirror (env-seeded pool entries, model cache rows, - # value-matched config.yaml copies) or the provider resurrects in the picker after restart. - if pconfig and pconfig.api_key_env_vars: - for ev in pconfig.api_key_env_vars: - if remove_provider_env_credential(ev).get("found"): - cleared_env = True - - # Full disconnect: removing OAuth grants is intended here, unlike key-only deletes. - cleared_auth = clear_provider_auth(slug) - if not cleared_env and not cleared_auth: - return _err(rid, 4005, f"no credentials found for {slug}") - return _ok(rid, {"slug": slug, "name": pconfig.name if pconfig else slug, "disconnected": True}) - except Exception as e: - return _err(rid, 5035, str(e)) + from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth + from hermes_cli.credential_lifecycle import remove_provider_env_credential + slug = (params.get("slug") or "").strip() + if not slug: + return _err(rid, 4001, "slug is required") + pconfig = PROVIDER_REGISTRY.get(slug) + # Remove EVERY env var plus its mirrors (env-seeded pool entries, model cache rows, + # value-matched config.yaml copies) or the provider resurrects in the picker after restart. + env_vars = (pconfig.api_key_env_vars if pconfig else None) or () + cleared_env = any([remove_provider_env_credential(ev).get("found") for ev in env_vars]) + # Full disconnect: removing OAuth grants is intended here, unlike key-only deletes. + cleared_auth = clear_provider_auth(slug) + if not cleared_env and not cleared_auth: + return _err(rid, 4005, f"no credentials found for {slug}") + return _ok(rid, {"slug": slug, "name": pconfig.name if pconfig else slug, "disconnected": True}) def register(server) -> None: diff --git a/tui_gateway/methods_complete_helpers.py b/tui_gateway/methods_complete_helpers.py index 1e5df8c83c..b6631ab069 100644 --- a/tui_gateway/methods_complete_helpers.py +++ b/tui_gateway/methods_complete_helpers.py @@ -19,8 +19,7 @@ _FUZZY_CACHE_TTL_S = 5.0 _FUZZY_CACHE_MAX_FILES = 20000 _FUZZY_FALLBACK_EXCLUDES = frozenset( {".git", ".hg", ".svn", ".next", ".cache", ".venv", "venv", "node_modules", "__pycache__", - "dist", "build", "target", ".mypy_cache", ".pytest_cache", ".ruff_cache"} -) + "dist", "build", "target", ".mypy_cache", ".pytest_cache", ".ruff_cache"}) _fuzzy_cache_lock = threading.Lock() _fuzzy_cache: dict[str, tuple[float, list[str]]] = {} @@ -35,10 +34,8 @@ def _list_repo_files(root: str) -> list[str]: cached = _fuzzy_cache.get(root) if cached and now - cached[0] < _FUZZY_CACHE_TTL_S: return cached[1] - files: list[str] = [] from hermes_cli._subprocess_compat import windows_hide_flags - run_kw = dict(capture_output=True, timeout=2.0, check=False, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) try: top_result = subprocess.run(["git", "-C", root, "rev-parse", "--show-toplevel"], **run_kw) @@ -59,7 +56,6 @@ def _list_repo_files(root: str) -> list[str]: break except (OSError, subprocess.TimeoutExpired): pass - if not files: # Fallback walk skips vendor/build dirs + dot-dirs; dotfiles survive (the ranker # decides based on whether the query starts with `.`). @@ -76,10 +72,8 @@ def _list_repo_files(root: str) -> list[str]: break except OSError: pass - with _fuzzy_cache_lock: _fuzzy_cache[root] = (now, files) - return files @@ -89,13 +83,10 @@ def _fuzzy_basename_rank(name: str, query: str) -> tuple[int, int] | None: · 3 substring · 4 subsequence (query chars appear in order).""" if not query: return (3, len(name)) - nl = name.lower() ql = query.lower() - if nl == ql: return (0, len(name)) - if nl.startswith(ql): return (1, len(name)) @@ -115,17 +106,14 @@ def _fuzzy_basename_rank(name: str, query: str) -> tuple[int, int] | None: for p in parts: if p.lower().startswith(ql): return (2, len(name)) - if ql in nl: return (3, len(name)) - i = 0 for ch in nl: if ch == ql[i]: i += 1 if i == len(ql): return (4, len(name)) - return None @@ -136,13 +124,10 @@ def _abs_completion_prefix_exists(path_part: str) -> bool: expanded = _normalize_completion_path(path_part) parent = os.path.dirname(expanded.rstrip("/")) or "/" tail = os.path.basename(expanded.rstrip("/")) - if not os.path.isdir(parent): return False - if not tail or expanded.endswith("/"): return os.path.isdir(expanded) or expanded == "/" - try: tail_lower = tail.lower() return any(e.lower().startswith(tail_lower) for e in os.listdir(parent)) @@ -171,11 +156,9 @@ def _details_root_meta(candidate: str) -> str: def _details_completions(text: str) -> list[dict] | None: if not text.lower().startswith("/details"): return None - stripped = text.strip() if stripped and not "/details".startswith(stripped.lower().split()[0]): return None - body = text[len("/details") :] if body.startswith(" "): body = body[1:] @@ -183,41 +166,33 @@ def _details_completions(text: str) -> list[dict] | None: has_trailing_space = text.endswith(" ") sections, modes = _DETAILS_SECTIONS, _DETAILS_MODES root_candidates = (*modes, "cycle", *sections) - if not body or (len(parts) == 0 and has_trailing_space): return [_details_root_completion_item(c, _details_root_meta(c), not has_trailing_space) for c in root_candidates] - if len(parts) == 1 and not has_trailing_space: prefix = parts[0].lower() return [ _details_completion_item(c, _details_root_meta(c)) for c in root_candidates - if c.startswith(prefix) and c != prefix - ] - + if c.startswith(prefix) and c != prefix] section = parts[0].lower() if parts else "" if section not in sections: return [] def section_meta(candidate: str) -> str: return f"clear {section} override" if candidate == "reset" else f"set {section}" - if len(parts) == 1 and has_trailing_space: return [_details_completion_item(c, section_meta(c)) for c in (*modes, "reset")] - if len(parts) == 2 and not has_trailing_space: prefix = parts[1].lower() return [ _details_completion_item(c, section_meta(c)) for c in (*modes, "reset") if c.startswith(prefix) and c != prefix ] - return [] def _model_picker_context(agent): """Layer live session state onto config without losing custom identity.""" from hermes_cli.inventory import load_picker_context - ctx = load_picker_context() provider = getattr(agent, "provider", "") if agent else "" base_url = getattr(agent, "base_url", "") if agent else "" @@ -225,16 +200,13 @@ def _model_picker_context(agent): if str(provider or "").strip().lower() == "custom": try: from hermes_cli.runtime_provider import canonical_custom_identity - provider = ( canonical_custom_identity( base_url=base_url or None, config_provider=ctx.current_provider, model=model or None ) - or provider - ) + or provider) except Exception: logger.debug("custom provider identity recovery failed (model picker)", exc_info=True) - return ctx.with_overrides( current_provider=provider, current_model=model or _resolve_model(), current_base_url=base_url ) diff --git a/tui_gateway/methods_slash.py b/tui_gateway/methods_slash.py index b8155536e1..270da69700 100644 --- a/tui_gateway/methods_slash.py +++ b/tui_gateway/methods_slash.py @@ -43,15 +43,12 @@ def _format_live_review_output(session: Optional[dict], arg: str) -> str: return "Nothing to review yet — send a message first." if session.get("running"): return "session busy — wait for the current turn to finish, then /review" - with session.get("history_lock") or contextlib.nullcontext(): snapshot = list(session.get("history", [])) if not snapshot: snapshot = list(getattr(agent, "_session_messages", None) or []) - try: from agent.review_engine import format_dispatch_note, start_review - result = start_review(agent, snapshot, arg or "") except ValueError as exc: return str(exc) @@ -73,27 +70,23 @@ def _format_live_usage_output(session: dict) -> str: def n(key: str) -> str: return f"{int(usage.get(key) or 0):,}" - lines = [ "Session Token Usage", "────────────────────────────────────────", f"Model: {usage.get('model') or _metadata_mirror(session).get('model') or getattr(agent, 'model', '') or '(unknown)'}", f"Input tokens: {n('input')}", - f"Output tokens: {n('output')}", - ] + f"Output tokens: {n('output')}"] if int(usage.get("reasoning") or 0): lines.append(f"Reasoning tokens: {n('reasoning')}") lines += [ f"Prompt tokens: {n('prompt')}", f"Completion tokens: {n('completion')}", f"Total tokens: {n('total')}", - f"API calls: {n('calls')}", - ] + f"API calls: {n('calls')}"] if usage.get("context_max"): lines.append( f"Current context: {n('context_used')} / {n('context_max')} " - f"({int(usage.get('context_percent') or 0)}%)" - ) + f"({int(usage.get('context_percent') or 0)}%)") lines += [f"Messages: {message_count:,}", f"Compressions: {n('compressions')}"] return "\n".join(lines) @@ -106,8 +99,7 @@ def _live_session_messages(session: dict) -> Optional[list]: if db is not None and session.get("session_key"): try: return db.get_messages_as_conversation( - session["session_key"], include_ancestors=True, include_row_ids=True - ) + session["session_key"], include_ancestors=True, include_row_ids=True) except Exception: pass return None @@ -140,8 +132,7 @@ def _format_live_prompt_output(session: dict) -> str: mirror.get("system_prompt") or getattr(agent, "ephemeral_system_prompt", None) or getattr(agent, "_cached_system_prompt", None) - or "" - ) + or "") if not prompt: return "Current system prompt is not built yet; send a message first." return f"Current system prompt:\n{prompt}" @@ -164,8 +155,7 @@ def _format_live_context_output(session: dict) -> str: roles[role] = roles.get(role, 0) + 1 lines.append( f" user: {roles.get('user', 0)}, assistant: {roles.get('assistant', 0)}, " - f"tool: {roles.get('tool', 0)}, system: {roles.get('system', 0)}" - ) + f"tool: {roles.get('tool', 0)}, system: {roles.get('system', 0)}") model = mirror.get("model") or usage.get("model") or "" if model: lines.append(f"Model: {model}") @@ -197,7 +187,6 @@ def _format_live_tools_output(session: dict) -> str: def _format_live_help_output() -> str: try: from hermes_cli.commands import COMMANDS_BY_CATEGORY - lines = ["Available commands:", ""] for category, commands in COMMANDS_BY_CATEGORY.items(): lines.append(f"{category}:") @@ -238,8 +227,7 @@ _LIVE_SLASH_OUTPUT = { "clear": (None, lambda sid, s, a: "Screen clear is terminal-only; desktop/TUI chat left unchanged."), "models": (None, lambda sid, s, a: "Use /model to view or switch the current model; desktop users can also open the model picker."), "rename": (None, lambda sid, s, a: "Use /title to rename this session."), - "effort": (None, lambda sid, s, a: "Use /reasoning to change reasoning effort."), -} + "effort": (None, lambda sid, s, a: "Use /reasoning to change reasoning effort.")} def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg: str) -> Optional[str]: @@ -270,21 +258,21 @@ def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg _MUTATES_WHILE_RUNNING = frozenset({"model", "personality", "prompt", "compress"}) -def _compress_live_with_feedback(sid: str, session: dict, agent, arg: str, *, snapshot_kwargs: bool) -> dict: - """Compress the live session; return the ``summarize_manual_compression`` dict. +def _compress_live_with_feedback(sid: str, session: dict, agent, arg: str, *, snapshot_kwargs: bool) -> str: + """Compress the live session; return the user-facing feedback text. Shared by command.dispatch /compress and the slash mirror so every route shows "compressed N → M messages / ~X → ~Y tokens". ``snapshot_kwargs`` forwards the pre-read snapshot (approx_tokens/before_messages/history_version) to ``_compress_session_history``; the slash mirror passes only the raw arg. The raw arg goes through unparsed — the choke point parses ``here [N]`` / ``--keep N``. - CompressionLockHeld and other errors propagate to the caller, which finalizes - the deferred context-engine notification. + CompressionLockHeld is a clean no-op (its skip note is returned; the choke point + already discarded the deferred context-engine notification); other errors propagate + to the caller, which finalizes that notification. """ from agent.conversation_compression import finalize_context_engine_compression_notification - from agent.manual_compression_feedback import summarize_manual_compression + from agent.manual_compression_feedback import describe_compression_lock_skip, summarize_manual_compression from agent.model_metadata import estimate_request_tokens_rough - with session["history_lock"]: before_messages = list(session.get("history", [])) history_version = int(session.get("history_version", 0)) @@ -293,38 +281,29 @@ def _compress_live_with_feedback(sid: str, session: dict, agent, arg: str, *, sn before_tokens = ( estimate_request_tokens_rough(before_messages, system_prompt=sys_prompt, tools=tools) if before_messages else 0 ) - if snapshot_kwargs: - _compress_session_history( - session, - arg.strip() or None, - approx_tokens=before_tokens, - before_messages=before_messages, - history_version=history_version, - ) - else: - _compress_session_history(session, arg) + try: + if snapshot_kwargs: + _compress_session_history( + session, arg.strip() or None, approx_tokens=before_tokens, before_messages=before_messages, + history_version=history_version) + else: + _compress_session_history(session, arg) + except CompressionLockHeld as e: + return describe_compression_lock_skip(e.holder) _sync_session_key_after_compress(sid, session) with session["history_lock"]: after_messages = list(session.get("history", [])) after_tokens = ( estimate_request_tokens_rough( - after_messages, - system_prompt=getattr(agent, "_cached_system_prompt", "") or sys_prompt, - tools=getattr(agent, "tools", None) or tools, - ) - if after_messages - else 0 - ) + after_messages, system_prompt=getattr(agent, "_cached_system_prompt", "") or sys_prompt, + tools=getattr(agent, "tools", None) or tools) + if after_messages else 0) _emit("session.info", sid, _session_info(agent, session)) fb = summarize_manual_compression( - before_messages, - after_messages, - before_tokens, - after_tokens, - compression_state=getattr(agent, "context_compressor", None), - ) + before_messages, after_messages, before_tokens, after_tokens, + compression_state=getattr(agent, "context_compressor", None)) finalize_context_engine_compression_notification(agent, committed=True) - return fb + return "\n".join(filter(None, [fb["headline"], fb["token_line"], fb.get("note")])) def _mirror_model(sid, session, agent, arg) -> str: @@ -345,7 +324,6 @@ def _mirror_personality(sid, session, agent, arg) -> str: pname, new_prompt = _validate_personality(arg, _load_cfg()) # Persist through the single owner so this surface never drifts from the others. from hermes_cli.personality import persist_personality - persist_personality(pname) _apply_personality_to_session(sid, session, new_prompt, pname) return "" @@ -360,29 +338,17 @@ def _mirror_prompt(sid, session, agent, arg) -> str: def _mirror_compress(sid, session, agent, arg) -> str: - if not agent: - return "" - try: - fb = _compress_live_with_feedback(sid, session, agent, arg, snapshot_kwargs=False) - except CompressionLockHeld as e: - from agent.manual_compression_feedback import describe_compression_lock_skip + return _compress_live_with_feedback(sid, session, agent, arg, snapshot_kwargs=False) if agent else "" - return describe_compression_lock_skip(e.holder) - lines = [fb["headline"], fb["token_line"]] - if fb.get("note"): - lines.append(fb["note"]) - return "\n".join(lines) + +_FAST_TIERS = {"fast": "priority", "on": "priority", "normal": None, "off": None, "auto": "auto", "cold": "cold"} def _mirror_fast(sid, session, agent, arg) -> str: if agent: mode = arg.lower() - if mode in {"fast", "on"}: - agent.service_tier = "priority" - elif mode in {"normal", "off"}: - agent.service_tier = None - elif mode in {"auto", "cold"}: - agent.service_tier = mode + if mode in _FAST_TIERS: + agent.service_tier = _FAST_TIERS[mode] _emit("session.info", sid, _session_info(agent, session)) return "" @@ -395,7 +361,6 @@ def _mirror_reload_mcp(sid, session, agent, arg) -> str: def _mirror_stop(sid, session, agent, arg) -> str: from tools.process_registry import process_registry - process_registry.kill_all() return "" @@ -408,8 +373,7 @@ _SLASH_MIRRORS = { "compress": _mirror_compress, "fast": _mirror_fast, "reload-mcp": _mirror_reload_mcp, - "stop": _mirror_stop, -} + "stop": _mirror_stop} def _compute_host_slash(sid: str, session: dict, name: str, command: str) -> tuple[str, str]: @@ -426,13 +390,9 @@ def _compute_host_slash(sid: str, session: dict, name: str, command: str) -> tup def _on_late_ack(late: dict, _sid=sid) -> None: _adopt_late_compute_host_compress_ack(_sid, _late_session, late, route_name=route_name) - try: ack = _send_compute_host_control( - sid, - route_name=route_name, - command=command, - wait=True, + sid, route_name=route_name, command=command, wait=True, **({"timeout": _compute_host_compress_wait_seconds(), "on_late_ack": _on_late_ack} if is_compress else {}), ) except queue.Empty: @@ -457,12 +417,10 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: # /compact aliases /compress everywhere; the compute-host control forwards the # raw alias verbatim, so without this the child mirror silently no-ops. name = "compress" - if _session_uses_compute_host(session) and name in _MUTATES_WHILE_RUNNING: return _compute_host_slash(sid, session, name, command)[1] if name in _MUTATES_WHILE_RUNNING and session.get("running"): return f"session busy — /interrupt the current turn before running /{name}" - mirror = _SLASH_MIRRORS.get(name) if mirror is None: return "" @@ -471,7 +429,6 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: except Exception as e: if name == "compress" and agent: from agent.conversation_compression import finalize_context_engine_compression_notification - finalize_context_engine_compression_notification(agent, committed=False) return f"live session sync failed: {e}" diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 6002256861..5657537088 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -39,7 +39,6 @@ def _profile_scoped_rpc(fail_code: int, *, required=(), catch_resolve: bool = Tr try: from hermes_cli.profiles import get_profile_dir from hermes_constants import set_hermes_home_override - profile_dir = get_profile_dir(profile) if not profile_dir or not profile_dir.is_dir(): return _err(rid, 4064, f"profile '{profile}' not found") @@ -54,10 +53,8 @@ def _profile_scoped_rpc(fail_code: int, *, required=(), catch_resolve: bool = Tr return _err(rid, fail_code, f"{prefix}{e}") finally: _mcp_reset_profile(token) - handler.__doc__ = body.__doc__ return handler - return deco @@ -66,6 +63,24 @@ def _guarded(fail_code: int, prefix: str = ""): return _profile_scoped_rpc(fail_code, prefix=prefix, scoped=False) +def _live_session_guarded(fail_code: int): + """Resolve the session via ``_sess`` (waits for the agent build) and call + ``body(rid, params, session)``; body exceptions → ``fail_code``.""" + + def deco(body): + def handler(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + try: + return body(rid, params, session) + except Exception as e: + return _err(rid, fail_code, str(e)) + handler.__doc__ = body.__doc__ + return handler + return deco + + def _stripped(v) -> bool: return bool(str(v or "").strip()) @@ -86,7 +101,6 @@ def _mcp_server_scoped(body): def _mcp_named_server(rid, params): """(name, servers, None) for a configured server, else (name, servers, 4064 error).""" from hermes_cli.mcp_config import _get_mcp_servers - name = str(params.get("name") or "").strip() servers = _get_mcp_servers() err = None if name in servers else _err(rid, 4064, f"server '{name}' not found") @@ -112,7 +126,6 @@ def _session_key_or_err(rid, session): def _user_turn_indices(session): """(history, indices of user-originated turns) minus ephemeral scaffolding. Call under history_lock.""" from agent.context_compressor import user_originated_turn_view - history = _history_without_ephemeral_scaffolding(session.get("history", [])) return history, [i for i, m in enumerate(history) if user_originated_turn_view(m) is not None] @@ -126,7 +139,6 @@ def _capture_run_kwargs(timeout: int) -> dict: text, UTF-8 + lossy decode (non-UTF-8 child output must not crash the gateway thread on locale-mismatched Windows), no stdin, no console flash under the desktop parent.""" from hermes_cli._subprocess_compat import windows_hide_flags - return dict( capture_output=True, text=True, @@ -134,13 +146,11 @@ def _capture_run_kwargs(timeout: int) -> dict: errors="replace", timeout=timeout, stdin=subprocess.DEVNULL, - creationflags=windows_hide_flags(), - ) + creationflags=windows_hide_flags()) def _toolset_rows(params: dict, *, with_tools: bool) -> list[dict]: from toolsets import get_all_toolsets, get_toolset_info - session = _sessions.get(params.get("session_id", "")) enabled = ( set(getattr(session["agent"], "enabled_toolsets", []) or []) if session else set(_load_enabled_toolsets() or []) @@ -154,8 +164,7 @@ def _toolset_rows(params: dict, *, with_tools: bool) -> list[dict]: "name": name, "description": info["description"], "tool_count": info["tool_count"], - "enabled": name in enabled if enabled else True, - } + "enabled": name in enabled if enabled else True} if with_tools: row["tools"] = info["resolved_tools"] items.append(row) @@ -170,7 +179,6 @@ def _(rid, params: dict) -> dict: """Host battery for the status bar. Always resolves; ``available: false`` = no battery or read failed.""" try: from agent.battery import battery_category, read_battery - batt = read_battery() return _ok( rid, @@ -178,9 +186,7 @@ def _(rid, params: dict) -> dict: "available": batt.available, "percent": batt.percent, "plugged": batt.plugged, - "category": battery_category(batt), - }, - ) + "category": battery_category(batt)}) except Exception: return _ok(rid, {"available": False, "percent": None, "plugged": None, "category": "dim"}) @@ -189,47 +195,34 @@ def _(rid, params: dict) -> dict: @_guarded(5010) def _(rid, params: dict) -> dict: from tools.process_registry import process_registry - return _ok(rid, {"killed": process_registry.kill_all()}) @method("process.list") -def _(rid, params: dict) -> dict: +@_live_session_guarded(5010) +def _(rid, params: dict, session) -> dict: """Session-scoped view of the background process registry (desktop status stack).""" - session, err = _sess(params, rid) - if err: - return err - try: - return _ok(rid, {"processes": _session_processes(session)}) - except Exception as e: - return _err(rid, 5010, str(e)) + return _ok(rid, {"processes": _session_processes(session)}) @method("process.kill") -def _(rid, params: dict) -> dict: +@_live_session_guarded(5010) +def _(rid, params: dict, session) -> dict: """Kill ONE background process, scoped to the caller's session (unlike process.stop's kill_all).""" - session, err = _sess(params, rid) - if err: - return err proc_id = str(params.get("process_id") or "") if not proc_id: return _err(rid, 4012, "process_id required") - try: - from tools.process_registry import process_registry - - proc = process_registry.get(proc_id) - if proc is None or str(getattr(proc, "session_key", "") or "") != str(session.get("session_key") or ""): - return _err(rid, 4044, f"no such process: {proc_id}") - return _ok(rid, process_registry.kill_process(proc_id)) - except Exception as e: - return _err(rid, 5010, str(e)) + from tools.process_registry import process_registry + proc = process_registry.get(proc_id) + if proc is None or str(getattr(proc, "session_key", "") or "") != str(session.get("session_key") or ""): + return _err(rid, 4044, f"no such process: {proc_id}") + return _ok(rid, process_registry.kill_process(proc_id)) def _mcp_reload_confirm_required() -> bool: """``approvals.mcp_reload_confirm`` from disk config; True (safe) on any failure.""" try: from hermes_cli.config import load_config - cfg = load_config() approvals = cfg.get("approvals") if isinstance(cfg, dict) else None return bool(approvals.get("mcp_reload_confirm", True)) if isinstance(approvals, dict) else True @@ -248,19 +241,15 @@ def _(rid, params: dict) -> dict: message = ( "⚠️ /reload-mcp invalidates the prompt cache (next message re-sends full input tokens). " "Reply `/reload-mcp now` to proceed, or `/reload-mcp always` to proceed and " - "silence this prompt permanently." - ) + "silence this prompt permanently.") return _ok(rid, {"status": "confirm_required", "message": message}) - if session and _session_uses_compute_host(session): try: ack = _get_compute_host_supervisor().reload_mcp( - str(params.get("session_id") or ""), request_id=f"reload-mcp-{rid}" - ) + str(params.get("session_id") or ""), request_id=f"reload-mcp-{rid}") except Exception as exc: return _err(rid, 5019, f"compute-host reload_mcp failed: {exc}") return _ok(rid, {"status": "reloaded", "turn_isolation": True, "host_ack": ack}) - from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, reprobe_tool_availability def _refresh_session_agent() -> None: @@ -279,7 +268,6 @@ def _(rid, params: dict) -> dict: except Exception as _exc: logger.warning("Failed to refresh cached agent tools after /reload-mcp: %s", _exc) _emit("session.info", params.get("session_id", ""), _session_info(agent, session)) - global _mcp_reload_gen, _mcp_reload_loaded_rev # Revision the CALLER wants loaded (the mcp_rev its poll observed); empty on @@ -292,7 +280,6 @@ def _(rid, params: dict) -> dict: down mid-rebuild. Config can change WHILE discover connects: re-hash after discovery and repeat until stable so the marked generation matches what loaded.""" global _mcp_reload_gen, _mcp_reload_loaded_rev - loaded = _compute_mcp_rev() for _ in range(_MCP_RELOAD_MAX_PASSES): shutdown_mcp_servers() @@ -302,7 +289,6 @@ def _(rid, params: dict) -> dict: if after == loaded: break loaded = after - _refresh_session_agent() _mcp_reload_loaded_rev = loaded _mcp_reload_gen += 1 @@ -317,22 +303,17 @@ def _(rid, params: dict) -> dict: _do_full_reload() finally: _mcp_reload_lock.release() - return _finish_reload(rid, params, coalesced=False) - gen_before = _mcp_reload_gen - with _mcp_reload_lock: leader_completed = _mcp_reload_gen > gen_before rev_satisfied = not req_rev or req_rev == _mcp_reload_loaded_rev - if leader_completed and rev_satisfied: _refresh_session_agent() coalesced = True else: _do_full_reload() coalesced = False - return _finish_reload(rid, params, coalesced=coalesced) @@ -342,7 +323,6 @@ def _(rid, params: dict) -> dict: """Re-read ``~/.hermes/.env`` (classic CLI ``/reload`` parity). Already-built agents keep their credential pool / provider routing; ``/new`` resolves fresh.""" from hermes_cli.config import reload_env - return _ok(rid, {"updated": int(reload_env())}) @@ -354,7 +334,6 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: """Registry-backed slash metadata for the TUI — categorized, no aliases.""" from hermes_cli.commands import COMMAND_REGISTRY, SUBCOMMANDS, _build_description, command_desktop_meta - all_pairs: list[list[str]] = [] canon: dict[str, str] = {} commands: dict[str, dict[str, str | None]] = {} @@ -371,7 +350,6 @@ def _(rid, params: dict) -> dict: canon[key.lower()] = key all_pairs.append([key, desc]) rows.append([key, desc]) - for cmd in COMMAND_REGISTRY: meta = command_desktop_meta(cmd) commands[f"/{cmd.name}"] = dict(meta) @@ -383,12 +361,10 @@ def _(rid, params: dict) -> dict: add(c, _build_description(cmd), bucket(cmd.category)) for a in cmd.aliases: canon[f"/{a}".lower()] = c - for name, desc, cat in _TUI_EXTRA: # Registry command/alias wins over a colliding TUI extra (e.g. /compact, /sessions). if name.lower() not in canon: add(name, desc, bucket(cat)) - warning = "" try: qcmds = _load_cfg().get("quick_commands", {}) or {} @@ -405,10 +381,8 @@ def _(rid, params: dict) -> dict: add(f"/{qname}", _clip(str(qc.get("description") or default_desc)), rows) except Exception as e: warning = f"quick_commands discovery unavailable: {e}" - try: from hermes_cli.plugins import get_plugin_commands - plugin_cmds = get_plugin_commands() or {} if plugin_cmds: rows = bucket("Plugin commands") @@ -427,7 +401,6 @@ def _(rid, params: dict) -> dict: except Exception as e: if not warning: warning = f"plugin command discovery unavailable: {e}" - skill_count = 0 skills: dict[str, dict] = {} try: @@ -435,7 +408,6 @@ def _(rid, params: dict) -> dict: # Usage + origin ride along (not a second RPC): every catalog consumer also ranks it. usage, origin_of = _skill_usage_lookup() - for k, info in sorted(scan_skill_commands().items()): all_pairs.append([k, _clip(str(info.get("description", "Skill")))]) name = str(info.get("name") or k.lstrip("/")) @@ -443,7 +415,6 @@ def _(rid, params: dict) -> dict: skill_count += 1 except Exception as e: warning = f"skill discovery unavailable: {e}" - payload = { "pairs": all_pairs, "sub": {k: v[:] for k, v in SUBCOMMANDS.items()}, @@ -452,8 +423,7 @@ def _(rid, params: dict) -> dict: "categories": [{"name": cat, "pairs": cat_map[cat]} for cat in cat_order], "skills": skills, "skill_count": skill_count, - "warning": warning, - } + "warning": warning} return _ok(rid, payload) @@ -472,8 +442,7 @@ def _(rid, params: dict) -> dict: cwd=os.getcwd(), # Can drive the agent → needs provider credentials; tier-1 secrets still stripped. env=hermes_subprocess_env(inherit_credentials=True), - **_capture_run_kwargs(min(int(params.get("timeout", 240)), 600)), - ) + **_capture_run_kwargs(min(int(params.get("timeout", 240)), 600))) parts = [r.stdout or "", r.stderr or ""] out = "\n".join(p for p in parts if p).strip() or "(no output)" return _ok(rid, {"blocked": False, "code": r.returncode, "output": out[:48_000]}) @@ -487,7 +456,6 @@ def _(rid, params: dict) -> dict: @_guarded(5012) def _(rid, params: dict) -> dict: from hermes_cli.commands import resolve_command - r = resolve_command(params.get("name", "")) if r: return _ok(rid, {"canonical": r.name, "description": r.description, "category": r.category}) @@ -506,13 +474,11 @@ def _dispatch_quick(rid, params, session, name, arg): if qc.get("type") == "exec": # Sanitized env: the TUI server process holds every API key in os.environ. from tools.environments.local import build_subprocess_env - sanitized_env = build_subprocess_env() r = subprocess.run(qc.get("command", ""), shell=True, env=sanitized_env, **_capture_run_kwargs(30)) output = ((r.stdout or "") + ("\n" if r.stdout and r.stderr else "") + (r.stderr or "")).strip()[:4000] if output: from agent.redact import redact_sensitive_text - output = redact_sensitive_text(output) if r.returncode != 0: return _err(rid, 4018, output or f"quick command failed with exit code {r.returncode}") @@ -525,12 +491,16 @@ def _dispatch_quick(rid, params, session, name, arg): def _plugin_command_handler(name: str): try: from hermes_cli.plugins import get_plugin_command_handler - return get_plugin_command_handler(name) except Exception: return None +def _run_plugin_command(handler, arg: str) -> str: + from hermes_cli.plugins import resolve_plugin_command_result + return str(resolve_plugin_command_result(handler(arg)) or "") + + def _is_profile_skill_command(session: dict, base: str) -> bool: """True when ``/base`` is a skill command of the session's profile. HERMES_HOME is bound to that profile so get_skill_commands() sees its skills.external_dirs: dispatch() runs on @@ -538,7 +508,6 @@ def _is_profile_skill_command(session: dict, base: str) -> bool: try: from agent.skill_commands import get_skill_commands from hermes_constants import reset_hermes_home_override, set_hermes_home_override - profile_home = session.get("profile_home") token = set_hermes_home_override(profile_home) if profile_home else None try: @@ -553,13 +522,8 @@ def _is_profile_skill_command(session: dict, base: str) -> bool: def _dispatch_plugin(rid, params, session, name, arg): handler = _plugin_command_handler(name) if handler: - try: - from hermes_cli.plugins import resolve_plugin_command_result - - result = resolve_plugin_command_result(handler(arg)) - return _ok(rid, {"type": "plugin", "output": str(result or "")}) - except Exception: - pass + with contextlib.suppress(Exception): + return _ok(rid, {"type": "plugin", "output": _run_plugin_command(handler, arg)}) return None @@ -568,7 +532,6 @@ def _bundle_key_for(name: str): try: from agent.skill_bundles import resolve_bundle_command_key from hermes_cli.commands import resolve_command - return resolve_bundle_command_key(name) if resolve_command(name) is None else None except Exception: return None @@ -579,19 +542,16 @@ def _dispatch_bundle(rid, params, session, name, arg): if bundle_key is None: return None from agent.skill_bundles import build_bundle_invocation_message, get_skill_bundles - try: bundle_result = build_bundle_invocation_message( bundle_key, arg, task_id=session.get("session_key", "") if session else "", - platform=_resolve_session_platform(), - ) + platform=_resolve_session_platform()) except Exception as exc: return _err(rid, 4018, f"bundle dispatch failed: {exc}") if not bundle_result: return _err(rid, 4018, f"failed to load bundle: {bundle_key}") - msg, loaded_names, missing = bundle_result bundle_name = get_skill_bundles().get(bundle_key, {}).get("name", bundle_key.lstrip("/")) notice = f"⚡ Loading bundle: {bundle_name} ({len(loaded_names)} skills)" @@ -604,7 +564,6 @@ def _dispatch_bundle(rid, params, session, name, arg): def _dispatch_skill(rid, params, session, name, arg): try: from agent.skill_commands import scan_skill_commands, build_skill_invocation_message - cmds = scan_skill_commands() key = f"/{name}" if key in cmds: @@ -631,21 +590,18 @@ def _cmd_queue(rid, params, session, name, arg): def _cmd_learn(rid, params, session, name, arg): # Submitted as a normal turn; the live agent gathers sources and authors the skill via skill_manage. from agent.learn_prompt import build_learn_prompt - return _ok(rid, {"type": "send", "message": build_learn_prompt(arg)}) def _cmd_plan(rid, params, session, name, arg): # Normal turn (as /learn); the agent saves the plan under .hermes/plans/ via write_file. from agent.plan_prompt import build_plan_prompt - return _ok(rid, {"type": "send", "message": build_plan_prompt(arg)}) def _cmd_init(rid, params, session, name, arg): # Generate-or-update AGENTS.md as a normal turn (as /learn). from hermes_cli.init_command import build_init_prompt_for_cwd - return _ok(rid, {"type": "send", "message": build_init_prompt_for_cwd(extra=arg)}) @@ -654,7 +610,6 @@ def _cmd_moa(rid, params, session, name, arg): # switching goes through the model picker (MoA presets = virtual "Mixture of Agents" provider). try: from hermes_cli.moa_config import moa_usage, normalize_moa_config - if not arg: return _err(rid, 4004, moa_usage()) if not session: @@ -667,8 +622,7 @@ def _cmd_moa(rid, params, session, name, arg): session["moa_one_shot_restore"] = { "override": session.get("model_override"), "model": getattr(agent, "model", None) if agent else None, - "provider": getattr(agent, "provider", None) if agent else None, - } + "provider": getattr(agent, "provider", None) if agent else None} if agent is not None: try: _apply_model_switch( @@ -689,8 +643,7 @@ def _cmd_moa(rid, params, session, name, arg): "model": preset, "base_url": "moa://local", "api_key": "moa-virtual-provider", - "api_mode": "chat_completions", - } + "api_mode": "chat_completions"} notice = f"MoA one-shot queued with preset {preset}; previous model will be restored after this turn." return _ok(rid, {"type": "send", "notice": notice, "message": arg}) except Exception as exc: @@ -700,23 +653,21 @@ def _cmd_moa(rid, params, session, name, arg): def _cmd_focus(rid, params, session, name, arg): # Display-only; routed through the config.set branch Ink uses so both surfaces share one state machine. from hermes_cli.focus_view import format_focus_status, format_focus_toggle_message, resolve_focus_arg - - _display_focus = _load_cfg().get("display") - _d_focus: dict = _display_focus if isinstance(_display_focus, dict) else {} - _cur_focus = bool(_d_focus.get("focus_view", False)) - _action, _target = resolve_focus_arg(arg, _cur_focus) - if _action == "usage": + display = _load_cfg().get("display") + display = display if isinstance(display, dict) else {} + cur = bool(display.get("focus_view", False)) + action, target = resolve_focus_arg(arg, cur) + if action == "usage": return _err(rid, 4004, "usage: /focus [on|off|status]") - if _action == "status": - _saved = _d_focus.get("focus_saved_tool_progress") or _load_tool_progress_mode() - return _ok(rid, {"type": "exec", "output": format_focus_status(_cur_focus, _saved)}) - _res = _methods["config.set"]( - rid, {"key": "focus", "value": "on" if _target else "off", "session_id": params.get("session_id", "")} + if action == "status": + saved = display.get("focus_saved_tool_progress") or _load_tool_progress_mode() + return _ok(rid, {"type": "exec", "output": format_focus_status(cur, saved)}) + res = _methods["config.set"]( + rid, {"key": "focus", "value": "on" if target else "off", "session_id": params.get("session_id", "")} ) - if "error" in _res: - return _res - _payload = _res.get("result") or {} - output = format_focus_toggle_message(bool(_target), _payload.get("tool_progress") or "all") + if "error" in res: + return res + output = format_focus_toggle_message(bool(target), (res.get("result") or {}).get("tool_progress") or "all") return _ok(rid, {"type": "exec", "output": output}) @@ -726,7 +677,6 @@ def _cmd_retry(rid, params, session, name, arg): if busy := _busy_error(rid, session, "retry"): return busy from agent.context_compressor import history_before_user_originated_turn, retryable_user_text - with session["history_lock"]: if busy := _busy_error(rid, session, "retry"): return busy @@ -742,8 +692,7 @@ def _cmd_retry(rid, params, session, name, arg): return _err(rid, 4018, str(exc)) try: _active, durable_live_view, _rewound_count = _rewind_active_session_history( - session, len(user_indices) - 1, require_retryable=True - ) + session, len(user_indices) - 1, require_retryable=True) except ValueError as exc: return _err(rid, 4018, str(exc)) except Exception as exc: @@ -768,21 +717,18 @@ def _cmd_steer(rid, params, session, name, arg): def _cmd_goal(rid, params, session, name, arg): - if not session: - return _err(rid, 4001, "no active session") + sid_key, err = _session_key_or_err(rid, session) + if err: + return err try: from hermes_cli.goals import GoalManager except Exception as exc: return _err(rid, 5030, f"goals unavailable: {exc}") - sid_key, err = _session_key_or_err(rid, session) - if err: - return err try: max_turns = int((_load_cfg().get("goals") or {}).get("max_turns", 20) or 20) except Exception: max_turns = 20 mgr = GoalManager(session_id=sid_key, default_max_turns=max_turns) - lower = arg.strip().lower() if not arg.strip() or lower == "status": return _ok(rid, {"type": "exec", "output": mgr.status_line()}) @@ -814,33 +760,28 @@ def _cmd_goal(rid, params, session, name, arg): notice = ( f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n" "I'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\n" - "Controls: /goal status · /goal pause · /goal resume · /goal clear" - ) + "Controls: /goal status · /goal pause · /goal resume · /goal clear") return _ok(rid, {"type": "send", "notice": notice, "message": state.goal}) def _cmd_loop(rid, params, session, name, arg): # Recurring in-session wakeups; the notification poller fires due ones while the session is idle. - if not session: - return _err(rid, 4001, "no active session") + sid_key, err = _session_key_or_err(rid, session) + if err: + return err try: from hermes_cli.loops import LoopManager, dispatch_loop_command except Exception as exc: return _err(rid, 5030, f"loops unavailable: {exc}") - sid_key, err = _session_key_or_err(rid, session) - if err: - return err result = dispatch_loop_command(LoopManager(session_id=sid_key), arg) output = result.get("output") or "" if result.get("created"): with contextlib.suppress(Exception): from hermes_cli.loops import goal_blocks_loop_tick - if goal_blocks_loop_tick(sid_key): output += ( "\nNote: an active /goal is driving this session — loop " - "wakeups defer until the goal finishes, pauses, or parks." - ) + "wakeups defer until the goal finishes, pauses, or parks.") return _ok(rid, {"type": "exec", "output": output}) @@ -862,7 +803,6 @@ def _cmd_undo(rid, params, session, name, arg): return _err(rid, 4004, f"undo: invalid count {arg_str!r} — use /undo or /undo N") n = max(n, 1) from agent.message_content import flatten_message_text - with session["history_lock"]: if busy := _busy_error(rid, session, "undo"): return busy @@ -912,7 +852,6 @@ def _cmd_compress(rid, params, session, name, arg): if busy := _busy_error(rid, session, "compress"): return busy from agent.conversation_compression import finalize_context_engine_compression_notification - sid = params.get("session_id", "") if _session_uses_compute_host(session): status, text = _compute_host_slash(sid, session, "compress", f"/{name}" + (f" {arg}" if arg else "")) @@ -921,15 +860,8 @@ def _cmd_compress(rid, params, session, name, arg): payload = {"type": "exec", "status": "pending", "output": text} if status == "pending" else {"type": "exec", "output": text} return _ok(rid, payload) try: - summary = _compress_live_with_feedback(sid, session, session["agent"], arg, snapshot_kwargs=True) - output = "\n".join(filter(None, [summary["headline"], summary["token_line"], summary.get("note")])) + output = _compress_live_with_feedback(sid, session, session["agent"], arg, snapshot_kwargs=True) return _ok(rid, {"type": "exec", "output": output}) - except CompressionLockHeld as e: - # Clean no-op (parity with the slash mirror / session.compress), never "compress failed"; - # _compress_session_history already discarded the deferred context-engine notification. - from agent.manual_compression_feedback import describe_compression_lock_skip - - return _ok(rid, {"type": "exec", "output": describe_compression_lock_skip(e.holder)}) except Exception as exc: finalize_context_engine_compression_notification(session["agent"], committed=False) return _err(rid, 5009, f"compress failed: {exc}") @@ -940,8 +872,7 @@ _SLASH_BUILTINS = { "queue": _cmd_queue, "q": _cmd_queue, "learn": _cmd_learn, "plan": _cmd_plan, "init": _cmd_init, "moa": _cmd_moa, "focus": _cmd_focus, "retry": _cmd_retry, "steer": _cmd_steer, "goal": _cmd_goal, "loop": _cmd_loop, "undo": _cmd_undo, "snapshot": _cmd_snapshot, "snap": _cmd_snapshot, - "compress": _cmd_compress, "compact": _cmd_compress, -} + "compress": _cmd_compress, "compact": _cmd_compress} @method("command.dispatch") @@ -968,49 +899,37 @@ def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) if err: return err - cmd = params.get("command", "").strip() if not cmd: return _err(rid, 4004, "empty command") # Skill/bundle and _PENDING_INPUT_COMMANDS must NOT reach the slash worker. Plugin # commands also bypass it but return normal slash.exec output (TUI keeps the pager path). - _cmd_text = cmd.lstrip("/") if cmd.startswith("/") else cmd - _cmd_parts = _cmd_text.split(maxsplit=1) - _cmd_base = (_cmd_parts[0] if _cmd_parts else "").lower() - _cmd_arg = _cmd_parts[1] if len(_cmd_parts) > 1 else "" + parts = cmd.lstrip("/").split(maxsplit=1) + base = (parts[0] if parts else "").lower() + arg = parts[1] if len(parts) > 1 else "" sid = params.get("session_id", "") - - live_output = _live_slash_command_output(sid, session, _cmd_base, _cmd_arg) + live_output = _live_slash_command_output(sid, session, base, arg) if live_output is not None: return _ok(rid, {"output": live_output or "(no output)"}) - - if _cmd_base in _PENDING_INPUT_COMMANDS: + if base in _PENDING_INPUT_COMMANDS: # Route straight to command.dispatch: some clients fail the error-then-retry fallback ("empty command"). - return _methods["command.dispatch"](rid, {"name": _cmd_base, "arg": _cmd_arg, "session_id": sid}) - - if _cmd_base in _WORKER_BLOCKED_COMMANDS: - subcommand = _cmd_arg.split(maxsplit=1)[0].lower() if _cmd_arg else "" + return _methods["command.dispatch"](rid, {"name": base, "arg": arg, "session_id": sid}) + if base in _WORKER_BLOCKED_COMMANDS: + subcommand = arg.split(maxsplit=1)[0].lower() if arg else "" if subcommand in {"restore", "rewind"}: return _err(rid, 4018, "snapshot restore mutates live config/state; use command.dispatch for /snapshot restore") - - _bundle_key = _bundle_key_for(_cmd_base) - if _bundle_key is not None: - return _methods["command.dispatch"](rid, {"name": _bundle_key.lstrip("/"), "arg": _cmd_arg, "session_id": sid}) - - if _is_profile_skill_command(session, _cmd_base): - return _err(rid, 4018, f"skill command: use command.dispatch for /{_cmd_base}") - - plugin_handler = _plugin_command_handler(_cmd_base) if _cmd_base else None + bundle_key = _bundle_key_for(base) + if bundle_key is not None: + return _methods["command.dispatch"](rid, {"name": bundle_key.lstrip("/"), "arg": arg, "session_id": sid}) + if _is_profile_skill_command(session, base): + return _err(rid, 4018, f"skill command: use command.dispatch for /{base}") + plugin_handler = _plugin_command_handler(base) if base else None if plugin_handler: try: - from hermes_cli.plugins import resolve_plugin_command_result - - result = resolve_plugin_command_result(plugin_handler(_cmd_arg)) - return _ok(rid, {"output": str(result or "(no output)")}) + return _ok(rid, {"output": _run_plugin_command(plugin_handler, arg) or "(no output)"}) except Exception as e: return _ok(rid, {"output": f"Plugin command error: {e}"}) - worker = session.get("slash_worker") if not worker: # slash.exec runs on the RPC pool: two concurrent commands could both see @@ -1025,12 +944,10 @@ def _(rid, params: dict) -> dict: worker = _SlashWorker( session["session_key"], getattr(session.get("agent"), "model", _resolve_model()), - profile_home=session.get("profile_home"), - ) + profile_home=session.get("profile_home")) _attach_worker(sid, session, worker) except Exception as e: return _err(rid, 5030, f"slash worker start failed: {e}") - try: output = worker.run(cmd) warning = _mirror_slash_side_effects(sid, session, cmd) @@ -1063,31 +980,21 @@ def _(rid, params: dict) -> dict: @method("rollback.list") -def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) - if err: - return err - try: - - def go(mgr, cwd): - if not mgr.enabled: - return _ok(rid, {"enabled": False, "checkpoints": []}) - rows = [ - {"hash": c.get("hash", ""), "timestamp": c.get("timestamp", ""), "message": c.get("message", "")} - for c in mgr.list_checkpoints(cwd) - ] - return _ok(rid, {"enabled": True, "checkpoints": rows}) - - return _with_checkpoints(session, go) - except Exception as e: - return _err(rid, 5020, str(e)) +@_live_session_guarded(5020) +def _(rid, params: dict, session) -> dict: + def go(mgr, cwd): + if not mgr.enabled: + return _ok(rid, {"enabled": False, "checkpoints": []}) + rows = [ + {"hash": c.get("hash", ""), "timestamp": c.get("timestamp", ""), "message": c.get("message", "")} + for c in mgr.list_checkpoints(cwd)] + return _ok(rid, {"enabled": True, "checkpoints": rows}) + return _with_checkpoints(session, go) @method("rollback.restore") -def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) - if err: - return err +@_live_session_guarded(5021) +def _(rid, params: dict, session) -> dict: target = params.get("hash", "") file_path = params.get("file_path", "") if not target: @@ -1096,46 +1003,37 @@ def _(rid, params: dict) -> dict: # would drop the agent's output or clobber it). File-scoped only touches disk. if not file_path and session.get("running"): return _err(rid, 4009, "session busy — /interrupt the current turn before full rollback.restore") - try: - def go(mgr, cwd): - resolved = _resolve_checkpoint_hash(mgr, cwd, target) - result = mgr.restore(cwd, resolved, file_path=file_path or None) - if result.get("success") and not file_path: - removed = 0 - with session["history_lock"]: - _history, user_indices = _user_turn_indices(session) - if user_indices: - try: - _active, _live_view, removed = _rewind_active_session_history(session, len(user_indices) - 1) - except Exception as exc: - raise RuntimeError(f"checkpoint restored, but session history rewind failed: {exc}") from exc - result["history_removed"] = removed - return result - - return _ok(rid, _with_checkpoints(session, go)) - except Exception as e: - return _err(rid, 5021, str(e)) + def go(mgr, cwd): + resolved = _resolve_checkpoint_hash(mgr, cwd, target) + result = mgr.restore(cwd, resolved, file_path=file_path or None) + if result.get("success") and not file_path: + removed = 0 + with session["history_lock"]: + _history, user_indices = _user_turn_indices(session) + if user_indices: + try: + _active, _live_view, removed = _rewind_active_session_history(session, len(user_indices) - 1) + except Exception as exc: + raise RuntimeError(f"checkpoint restored, but session history rewind failed: {exc}") from exc + result["history_removed"] = removed + return result + return _ok(rid, _with_checkpoints(session, go)) @method("rollback.diff") -def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) - if err: - return err +@_live_session_guarded(5022) +def _(rid, params: dict, session) -> dict: target = params.get("hash", "") if not target: return _err(rid, 4014, "hash required") - try: - r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, _resolve_checkpoint_hash(mgr, cwd, target))) - raw = r.get("diff", "")[:4000] - payload = {"stat": r.get("stat", ""), "diff": raw} - rendered = render_diff(raw, session.get("cols", 80)) - if rendered: - payload["rendered"] = rendered - return _ok(rid, payload) - except Exception as e: - return _err(rid, 5022, str(e)) + r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, _resolve_checkpoint_hash(mgr, cwd, target))) + raw = r.get("diff", "")[:4000] + payload = {"stat": r.get("stat", ""), "diff": raw} + rendered = render_diff(raw, session.get("cols", 80)) + if rendered: + payload["rendered"] = rendered + return _ok(rid, payload) @method("browser.manage") @@ -1155,11 +1053,9 @@ def _(rid, params: dict) -> dict: @_guarded(5032) def _(rid, params: dict) -> dict: from hermes_cli.plugins import get_plugin_manager - rows = [ {"name": n, "version": getattr(i, "version", "?"), "enabled": getattr(i, "enabled", True)} - for n, i in get_plugin_manager()._plugins.items() - ] + for n, i in get_plugin_manager()._plugins.items()] return _ok(rid, {"plugins": rows}) @@ -1169,15 +1065,13 @@ def _(rid, params: dict) -> dict: cfg = _load_cfg() model = _resolve_model() from agent.secret_scope import get_secret - api_key = get_secret("HERMES_API_KEY", "") or cfg.get("api_key", "") masked = f"****{api_key[-4:]}" if len(api_key) > 4 else "(not set)" base_url = os.environ.get("HERMES_BASE_URL", "") or cfg.get("base_url", "") agent_rows = [ ["Max Turns", str(_cfg_max_turns(cfg, 500))], ["Toolsets", ", ".join(cfg.get("enabled_toolsets", [])) or "all"], - ["Verbose", str(cfg.get("verbose", False))], - ] + ["Verbose", str(cfg.get("verbose", False))]] sections = [ {"title": "Model", "rows": [["Model", model], ["Base URL", base_url or "(default)"], ["API Key", masked]]}, {"title": "Agent", "rows": agent_rows}, @@ -1205,7 +1099,6 @@ def _(rid, params: dict) -> dict: @_guarded(5034) def _(rid, params: dict) -> dict: from model_tools import get_toolset_for_tool, get_tool_definitions - session = _sessions.get(params.get("session_id", "")) enabled = getattr(session["agent"], "enabled_toolsets", None) if session else _load_enabled_toolsets() # Pre-assembly list: /tools must also show tools deferred behind the tool_search bridge (as the CLI). @@ -1222,6 +1115,7 @@ def _(rid, params: dict) -> dict: @method("tools.configure") +@_guarded(5035) def _(rid, params: dict) -> dict: action = str(params.get("action", "") or "").strip().lower() targets = [str(name).strip() for name in params.get("names", []) or [] if str(name).strip()] @@ -1229,64 +1123,45 @@ def _(rid, params: dict) -> dict: return _err(rid, 4017, f"unknown tools action: {action}") if not targets: return _err(rid, 4018, "names required") - - try: - from hermes_cli.config import load_config, save_config - from hermes_cli.tools_config import ( - CONFIGURABLE_TOOLSETS, - _apply_mcp_change, - _apply_toolset_change, - _get_platform_tools, - _get_plugin_toolset_keys, - ) - - cfg = load_config() - valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys() - toolset_targets = [name for name in targets if ":" not in name] - mcp_targets = [name for name in targets if ":" in name] - unknown = [name for name in toolset_targets if name not in valid_toolsets] - toolset_targets = [name for name in toolset_targets if name in valid_toolsets] - - if toolset_targets: - _apply_toolset_change(cfg, "cli", toolset_targets, action) - - missing_servers = _apply_mcp_change(cfg, mcp_targets, action) if mcp_targets else set() - save_config(cfg) - - sid = params.get("session_id", "") - session = _sessions.get(sid) - info = _reset_session_agent(sid, session) if session else None - enabled = sorted(_get_platform_tools(load_config(), "cli", include_default_mcp_servers=False)) - changed = [ - name - for name in targets - if name not in unknown and (":" not in name or name.split(":", 1)[0] not in missing_servers) - ] - - return _ok( - rid, - { - "changed": changed, - "enabled_toolsets": enabled, - "info": info, - "missing_servers": sorted(missing_servers), - "reset": bool(session), - "unknown": unknown, - }, - ) - except Exception as e: - return _err(rid, 5035, str(e)) + from hermes_cli.config import load_config, save_config + from hermes_cli.tools_config import ( + CONFIGURABLE_TOOLSETS, _apply_mcp_change, _apply_toolset_change, _get_platform_tools, + _get_plugin_toolset_keys) + cfg = load_config() + valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys() + toolset_targets = [name for name in targets if ":" not in name] + mcp_targets = [name for name in targets if ":" in name] + unknown = [name for name in toolset_targets if name not in valid_toolsets] + toolset_targets = [name for name in toolset_targets if name in valid_toolsets] + if toolset_targets: + _apply_toolset_change(cfg, "cli", toolset_targets, action) + missing_servers = _apply_mcp_change(cfg, mcp_targets, action) if mcp_targets else set() + save_config(cfg) + sid = params.get("session_id", "") + session = _sessions.get(sid) + info = _reset_session_agent(sid, session) if session else None + enabled = sorted(_get_platform_tools(load_config(), "cli", include_default_mcp_servers=False)) + changed = [ + name + for name in targets + if name not in unknown and (":" not in name or name.split(":", 1)[0] not in missing_servers) + ] + return _ok(rid, { + "changed": changed, + "enabled_toolsets": enabled, + "info": info, + "missing_servers": sorted(missing_servers), + "reset": bool(session), + "unknown": unknown}) @method("agents.list") @_guarded(5033) def _(rid, params: dict) -> dict: from tools.process_registry import process_registry - rows = [ {"session_id": p["session_id"], "command": p["command"][:80], "status": p["status"], "uptime": p["uptime_seconds"]} - for p in process_registry.list_sessions() - ] + for p in process_registry.list_sessions()] return _ok(rid, {"processes": rows}) @@ -1299,7 +1174,6 @@ def _(rid, params: dict) -> dict: """cronjob() keys off HERMES_HOME, so the optional ``profile`` scope reaches a per-profile cron store even when that profile runs its own gateway.""" from tools.cronjob_tools import cronjob - action, jid = params.get("action", "list"), params.get("name", "") if action == "list": # Paused jobs are excluded by default (reads as deletion in a toggle UI) — forward the flag. @@ -1321,8 +1195,7 @@ def _(rid, params: dict) -> dict: prompt=params.get("prompt", ""), repeat=int(params["repeat"]) if str(params.get("repeat", "")).strip().isdigit() else None, continuity=is_truthy_value(params.get("continuity")) if params.get("continuity") is not None else None, - deliver=str(params.get("deliver") or "").strip() or None, - ) + deliver=str(params.get("deliver") or "").strip() or None) return _ok(rid, json.loads(raw)) if action in {"remove", "pause", "resume"}: return _ok(rid, json.loads(cronjob(action=action, job_id=jid))) @@ -1342,46 +1215,34 @@ def _(rid, params: dict) -> dict: cols, rows, frames = 80, 24, 48 from agent.learning_graph import build_learning_graph from agent.learning_graph_render import render_frames - return _ok(rid, render_frames(build_learning_graph(), cols=max(20, cols), rows=max(10, rows), frames=frames)) -@method("learning.detail") -@_guarded(5000, "learning.detail failed: ") -def _(rid, params: dict) -> dict: - """Current content of a journey node, for an edit prefill.""" - from agent.learning_mutations import node_detail +def _learning_mutation(fn_name: str, arg_keys: tuple): + """learning.* body: ``agent.learning_mutations.(*str(params[k]) for k in arg_keys)``.""" - return _ok(rid, node_detail(str(params.get("id", "")))) + def body(rid, params: dict) -> dict: + import agent.learning_mutations as mutations + return _ok(rid, getattr(mutations, fn_name)(*(str(params.get(k, "")) for k in arg_keys))) + return body -@method("learning.delete") -@_guarded(5000, "learning.delete failed: ") -def _(rid, params: dict) -> dict: - """Delete a journey node — skills are archived (restorable), memories removed.""" - from agent.learning_mutations import delete_node - - return _ok(rid, delete_node(str(params.get("id", "")))) - - -@method("learning.edit") -@_guarded(5000, "learning.edit failed: ") -def _(rid, params: dict) -> dict: - """Rewrite a journey node's content (SKILL.md or memory chunk).""" - from agent.learning_mutations import edit_node - - return _ok(rid, edit_node(str(params.get("id", "")), str(params.get("content", "")))) +# detail → node content for an edit prefill; delete → skills archived (restorable), memories +# removed; edit → rewrite a node's content (SKILL.md or memory chunk). +for _rpc, _fn, _keys in ( + ("detail", "node_detail", ("id",)), ("delete", "delete_node", ("id",)), ("edit", "edit_node", ("id", "content")), +): + method(f"learning.{_rpc}")(_guarded(5000, f"learning.{_rpc} failed: ")(_learning_mutation(_fn, _keys))) +del _rpc, _fn, _keys def _skills_list(rid, params, query): from hermes_cli.banner import get_available_skills - return _ok(rid, {"skills": get_available_skills()}) def _skills_search(rid, params, query): from tools.skills_hub import GitHubAuth, create_source_router, unified_search - raw = unified_search(query, create_source_router(GitHubAuth()), source_filter="all", limit=20) or [] return _ok(rid, {"results": [{"name": r.name, "description": r.description} for r in raw]}) @@ -1392,21 +1253,18 @@ def _skills_install(rid, params, query): class _Q: def print(self, *a, **k): pass - do_install(query, skip_confirm=True, console=_Q()) return _ok(rid, {"installed": True, "name": query}) def _skills_browse(rid, params, query): from hermes_cli.skills_hub import browse_skills - pg = int(params.get("page", 0) or 0) or (int(query) if query.isdigit() else 1) return _ok(rid, browse_skills(page=pg, page_size=int(params.get("page_size", 20)))) def _skills_inspect(rid, params, query): from hermes_cli.skills_hub import inspect_skill - return _ok(rid, {"info": inspect_skill(query) or {}}) @@ -1431,7 +1289,6 @@ def _(rid, params: dict) -> dict: @_guarded(5025) def _(rid, params: dict) -> dict: from agent.skill_commands import reload_skills - result = reload_skills() added = result.get("added") or [] removed = result.get("removed") or [] @@ -1457,7 +1314,6 @@ def _(rid, params: dict) -> dict: """``{servers: [{name, description, installed, enabled, requires: [env keys], transport}]}`` — the `hermes mcp` menu with per-profile state, so UIs know which entries need setup.""" from hermes_cli import mcp_catalog - out = [] for entry in mcp_catalog.list_catalog(): try: @@ -1472,9 +1328,7 @@ def _(rid, params: dict) -> dict: "installed": bool(mcp_catalog.is_installed(entry.name)), "enabled": bool(mcp_catalog.is_enabled(entry.name)), "requires": requires, - "transport": str(getattr(transport, "kind", "") or transport or "stdio"), - } - ) + "transport": str(getattr(transport, "kind", "") or transport or "stdio")}) return _ok(rid, {"servers": out}) @@ -1484,7 +1338,6 @@ def _(rid, params: dict) -> dict: """``{servers: [{name, transport, url, command, args, env (key names only), auth, oauth_tokens_present, enabled, tools}]}`` for the scoped profile.""" from hermes_cli.mcp_config import _get_mcp_servers - servers = _get_mcp_servers() return _ok(rid, {"servers": [_mcp_summarize_server(name, cfg) for name, cfg in sorted(servers.items())]}) @@ -1496,7 +1349,6 @@ def _(rid, params: dict) -> dict: headers/auth/tools). ``bearer_token`` goes to the profile's .env; only the ``Authorization`` header template is persisted. Duplicate names → 4090.""" from hermes_cli.mcp_config import _apply_mcp_preset, _get_mcp_servers, _save_bearer_auth_token, _save_mcp_server - name = str(params.get("name") or "").strip() if name in _get_mcp_servers(): return _err(rid, 4090, f"server '{name}' already exists") @@ -1510,9 +1362,7 @@ def _(rid, params: dict) -> dict: url=server_config.get("url"), command=server_config.get("command"), cmd_args=list(server_config.get("args") or []), - server_config=server_config, - ) - + server_config=server_config) if not server_config.get("url") and not server_config.get("command"): return _err(rid, 4063, "config must specify a 'url' (http) or 'command' (stdio), or a valid 'preset'") bearer_token = params.get("bearer_token") @@ -1532,7 +1382,6 @@ def _(rid, params: dict) -> dict: (stdio), matching ``cmd_mcp_configure`` / ``_save_bearer_auth_token``.""" from hermes_cli.config import load_config, save_config, save_env_value from hermes_cli.mcp_config import _bearer_auth_headers, _env_key_for_server, _strip_bearer_prefix - name, servers, err = _mcp_named_server(rid, params) if err: return err @@ -1541,7 +1390,6 @@ def _(rid, params: dict) -> dict: entry = servers[name] if not isinstance(entry, dict): return _err(rid, 4001, "malformed server config") - if entry.get("url"): normalized = _strip_bearer_prefix(str(value)) if not normalized or normalized.lower() == "bearer": @@ -1558,7 +1406,6 @@ def _(rid, params: dict) -> dict: env_block = {} env_block[env_var] = f"${{{env_var}}}" entry["env"] = env_block - cfg = load_config() cfg.setdefault("mcp_servers", {})[name] = entry save_config(cfg) @@ -1572,7 +1419,6 @@ def _(rid, params: dict) -> dict: oauth_tokens_present}``; failure: ``{ok: false, error, tools: [], oauth_needed, ...}``. Runs on the RPC pool (_LONG_HANDLERS): a cold stdio `npx` spawn can block for seconds.""" from hermes_cli.mcp_config import _oauth_tokens_present, _probe_single_server - name, servers, err = _mcp_named_server(rid, params) if err: return err @@ -1585,7 +1431,6 @@ def _(rid, params: dict) -> dict: def failure(error: str, oauth_needed: bool, tokens_present) -> dict: payload = {"ok": False, "error": error, "tools": [], "oauth_needed": oauth_needed} return _ok(rid, {**payload, "oauth_tokens_present": tokens_present}) - try: tools = _probe_single_server(name, cfg, details=details) token_present = _oauth_tokens_present(name) if needs_oauth_token else True @@ -1599,8 +1444,7 @@ def _(rid, params: dict) -> dict: "prompts": details.get("prompts", 0), "resources": details.get("resources", 0), "oauth_needed": needs_oauth_token, - "oauth_tokens_present": True if needs_oauth_token else None, - } + "oauth_tokens_present": True if needs_oauth_token else None} return _ok(rid, payload) @@ -1609,7 +1453,6 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: """Remove a server from the profile's config.yaml → ``{ok: true, removed: true}``.""" from hermes_cli.mcp_config import _remove_mcp_server - name = str(params.get("name") or "").strip() if not _remove_mcp_server(name): return _err(rid, 4064, f"server '{name}' not found") @@ -1630,7 +1473,6 @@ def _(rid, params: dict) -> dict: try: from hermes_constants import get_hermes_home from tui_gateway import mcp_oauth_sessions - name, servers, err = _mcp_named_server(rid, params) if err: return err @@ -1640,7 +1482,6 @@ def _(rid, params: dict) -> dict: if cfg.get("headers") and cfg.get("auth") != "oauth": return _err(rid, 4001, "this server uses header/API-key auth, not OAuth") cfg["auth"] = "oauth" - hermes_home = str(get_hermes_home().expanduser().resolve(strict=False)) result = mcp_oauth_sessions.start_flow(hermes_home, name, cfg, client_redirect_uri=client_redirect_uri) except ValueError as e: @@ -1654,7 +1495,6 @@ def _(rid, params: dict) -> dict: """Poll a flow → ``{ok, status: pending|approved|error, error_message?, auth_url?, tools?}``. On ``approved`` tokens persist for that server/profile (profile scope applies here too).""" from tui_gateway import mcp_oauth_sessions - name = str(params.get("name") or "").strip() session_id = str(params.get("session_id") or "").strip() result = mcp_oauth_sessions.poll_flow(session_id, name) @@ -1667,7 +1507,6 @@ def _(rid, params: dict) -> dict: """Relay a client-captured redirect (``code``/``state``/``error``) into a flow started with ``client_redirect_uri``. ``{ok: true}`` once accepted (state verified), else ``{ok: false, error_message}``.""" from tui_gateway import mcp_oauth_sessions - name = str(params.get("name") or "").strip() session_id = str(params.get("session_id") or "").strip() result = mcp_oauth_sessions.deliver_callback_flow( @@ -1675,8 +1514,7 @@ def _(rid, params: dict) -> dict: name, code=str(params.get("code") or "") or None, state=str(params.get("state") or "") or None, - error=str(params.get("error") or "") or None, - ) + error=str(params.get("error") or "") or None) return _ok(rid, result) @@ -1690,9 +1528,7 @@ def _plugin_rows() -> list[dict]: _get_disabled_set, _get_enabled_set, _is_portable_plugin_dir, - _plugin_status, - ) - + _plugin_status) enabled = _get_enabled_set() disabled = _get_disabled_set() out = [] @@ -1711,8 +1547,7 @@ def _plugin_rows() -> list[dict]: "source": source, "status": status, "portable": _is_portable_plugin_dir(_dir), # Agent Plugins v1 package vs native Hermes plugin - } - ) + }) return out @@ -1738,7 +1573,6 @@ def _plugins_toggle(rid, params): def _plugins_install(rid, params): from hermes_cli.plugins_cmd import dashboard_install_plugin - ident = (params.get("identifier") or params.get("repo") or "").strip() if not ident: return _err(rid, 4019, "plugins.install requires 'identifier' or 'repo'") @@ -1770,7 +1604,6 @@ def _(rid, params: dict) -> dict: return _err(rid, 4004, "empty command") try: from tools.approval import detect_dangerous_command, detect_hardline_command - is_hardline, hardline_desc = detect_hardline_command(cmd) if is_hardline: return _err(rid, 4005, f"blocked (hardline): {hardline_desc}. Use the agent for dangerous commands.") From 9bc478d617251204eadd999283a3bcb79e71fb36 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:13:38 -0700 Subject: [PATCH 2/7] refactor(tui_gateway): commands.catalog phase helpers, complete.path listing split, slash formatter table, captured-exec + oauth/plugin helpers --- tui_gateway/methods_complete.py | 153 +++++---- tui_gateway/methods_complete_helpers.py | 43 +-- tui_gateway/methods_slash.py | 60 ++-- tui_gateway/methods_tools.py | 402 ++++++++++++------------ 4 files changed, 313 insertions(+), 345 deletions(-) diff --git a/tui_gateway/methods_complete.py b/tui_gateway/methods_complete.py index 7f2869070c..821f5d6998 100644 --- a/tui_gateway/methods_complete.py +++ b/tui_gateway/methods_complete.py @@ -13,15 +13,10 @@ _profile_scoped = _registry.profile_scoped _BUILTIN_AT_PREFIXES = frozenset({"file", "folder", "url", "git", "diff", "staged"}) _AT_DIRECTIVE_HINTS = [ - ("@diff", "git diff"), - ("@staged", "staged diff"), - ("@file:", "attach file"), - ("@folder:", "attach folder"), - ("@url:", "fetch url"), - ("@git:", "git log")] + ("@diff", "git diff"), ("@staged", "staged diff"), ("@file:", "attach file"), + ("@folder:", "attach folder"), ("@url:", "fetch url"), ("@git:", "git log")] _SLASH_EXTRAS = [ - ("/density", "Toggle compact display mode"), - ("/details", "Control agent detail visibility"), + ("/density", "Toggle compact display mode"), ("/details", "Control agent detail visibility"), ("/logs", "Show recent gateway log lines"), ("/mouse", "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]")] @@ -138,35 +133,81 @@ def _fuzzy_basename_items(root: str, path_part: str, prefix_tag: str) -> list[di for _, rel, basename, is_dir in ranked[:30]] +def _at_root_items() -> list[dict]: + """Completions for a bare ``@``: directive hints, agent profiles, plugin ``@:`` providers.""" + items = [_item(t, m) for t, m in _AT_DIRECTIVE_HINTS] + items.extend(_profile_mention_items("")) + try: + from agent.context_references import get_context_reference_providers + for pfx, prov in sorted(get_context_reference_providers().items()): + items.append(_item(f"@{pfx}:", prov.description or f"plugin: {pfx}")) + except Exception: + pass + return items + + +def _dir_listing_items(root: str, word: str, path_part: str, prefix_tag: str, is_context: bool) -> list[dict]: + """Prefix-match entries of the directory ``path_part`` points at (max 30).""" + expanded = _normalize_completion_path(path_part) if path_part else "." + if expanded == "." or not expanded: + search_dir, match = ".", "" + elif expanded.endswith("/"): + search_dir, match = expanded, "" + else: + search_dir = os.path.dirname(expanded) or "." + match = os.path.basename(expanded) + search_dir = search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir) + items: list[dict] = [] + if not os.path.isdir(search_dir): + return items + want_dir = prefix_tag == "folder" + match_lower = match.lower() + for entry in sorted(os.listdir(search_dir)): + if match and not entry.lower().startswith(match_lower): + continue + if is_context and (entry in _FUZZY_FALLBACK_EXCLUDES or (not prefix_tag and entry.startswith("."))): + continue + full = os.path.join(search_dir, entry) + is_dir = os.path.isdir(full) + # Explicit `@folder:` / `@file:` skip the opposite kind (never rewrite the tag). + if prefix_tag and want_dir != is_dir: + continue + rel = os.path.relpath(full, root).replace(os.sep, "/") + suffix = "/" if is_dir else "" + if is_context and prefix_tag: + text = f"@{prefix_tag}:{rel}{suffix}" + elif is_context: + text = f"@{'folder' if is_dir else 'file'}:{rel}{suffix}" + elif word.startswith("~"): + text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix + elif word.startswith("./"): + text = "./" + rel + suffix + else: + text = rel + suffix + items.append(_item(text, "dir" if is_dir else "", entry + suffix)) + if len(items) >= 30: + break + return items + + @method("complete.path") def _(rid, params: dict) -> dict: word = params.get("word", "") if not word: return _ok(rid, {"items": []}) - items: list[dict] = [] try: root = _completion_cwd(params) is_context = word.startswith("@") query = word[1:] if is_context else word if is_context and not query: - items = [_item(t, m) for t, m in _AT_DIRECTIVE_HINTS] - items.extend(_profile_mention_items("")) # `@` alone reveals agent profiles too - try: - from agent.context_references import get_context_reference_providers - for _pfx, _prov in sorted(get_context_reference_providers().items()): - items.append(_item(f"@{_pfx}:", _prov.description or f"plugin: {_pfx}")) - except Exception: - pass - return _ok(rid, {"items": items}) - + return _ok(rid, {"items": _at_root_items()}) # Plugin `@:` runs before the built-in file/folder branching. if is_context and ":" in query: - _pfx, _, _qval = query.partition(":") - if _pfx not in _BUILTIN_AT_PREFIXES: - plugin_items = _plugin_reference_items(_pfx, _qval) + pfx, _, qval = query.partition(":") + if pfx not in _BUILTIN_AT_PREFIXES: + plugin_items = _plugin_reference_items(pfx, qval) if plugin_items is not None: return _ok(rid, {"items": plugin_items}) - # Bare `@folder` lists as soon as the keyword is typed (the static `@folder:` hint is not accepted). if is_context and query in {"file", "folder"}: prefix_tag, path_part = query, "" @@ -174,66 +215,23 @@ def _(rid, params: dict) -> dict: prefix_tag, _, path_part = query.partition(":") else: prefix_tag, path_part = "", query - # `@/foo` usually means "foo, from here": absolute only when that prefix exists, # else resolve relative to cwd (`@/Desktop` must not dead-end; `@/usr/local` still resolves). - if is_context and path_part.startswith("/") and not path_part.startswith("//"): - if not _abs_completion_prefix_exists(path_part): - path_part = path_part.lstrip("/") + if ( + is_context and path_part.startswith("/") and not path_part.startswith("//") + and not _abs_completion_prefix_exists(path_part)): + path_part = path_part.lstrip("/") + bare_mention = is_context and not prefix_tag and path_part and "/" not in path_part if is_context and path_part and len(path_part.strip()) >= 2 and "/" not in path_part and prefix_tag != "folder": items = _fuzzy_basename_items(root, path_part, prefix_tag) - if not prefix_tag: # bare `@name` may be an agent mention: profiles rank ABOVE file hits - items = _profile_mention_items(path_part) + items - return _ok(rid, {"items": items}) - expanded = _normalize_completion_path(path_part) if path_part else "." - if expanded == "." or not expanded: - search_dir, match = ".", "" - elif expanded.endswith("/"): - search_dir, match = expanded, "" else: - search_dir = os.path.dirname(expanded) or "." - match = os.path.basename(expanded) - search_dir = search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir) - if not os.path.isdir(search_dir): - return _ok(rid, {"items": []}) - want_dir = prefix_tag == "folder" - match_lower = match.lower() - for entry in sorted(os.listdir(search_dir)): - if match and not entry.lower().startswith(match_lower): - continue - if is_context and entry in _FUZZY_FALLBACK_EXCLUDES: - continue - if is_context and not prefix_tag and entry.startswith("."): - continue - full = os.path.join(search_dir, entry) - is_dir = os.path.isdir(full) - # Explicit `@folder:` / `@file:` skip the opposite kind (never rewrite the tag). - if prefix_tag and want_dir != is_dir: - continue - rel = os.path.relpath(full, root).replace(os.sep, "/") - suffix = "/" if is_dir else "" - if is_context and prefix_tag: - text = f"@{prefix_tag}:{rel}{suffix}" - elif is_context: - text = f"@{'folder' if is_dir else 'file'}:{rel}{suffix}" - elif word.startswith("~"): - text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix - elif word.startswith("./"): - text = "./" + rel + suffix - else: - text = rel + suffix - items.append(_item(text, "dir" if is_dir else "", entry + suffix)) - if len(items) >= 30: - break + items = _dir_listing_items(root, word, path_part, prefix_tag, is_context) except Exception as e: return _err(rid, 5021, str(e)) - - # Bare-word `@name` (incl. single chars, which skip the fuzzy branch): profiles rank above paths. - try: - if is_context and not prefix_tag and path_part and "/" not in path_part: + # Bare-word `@name` may be an agent mention: profiles rank ABOVE file hits. + if bare_mention: + with contextlib.suppress(Exception): items = _profile_mention_items(path_part) + items - except Exception: - pass return _ok(rid, {"items": items}) @@ -303,10 +301,8 @@ def _catch(fail_code: int): return body(rid, params) except Exception as e: return _err(rid, fail_code, str(e)) - handler.__doc__ = body.__doc__ return handler - return deco @@ -324,8 +320,7 @@ def _(rid, params: dict) -> dict: # NOT clobber disk config (with_overrides is truthy-only). ctx = _model_picker_context(_session_agent(params)) payload = build_model_options_payload( - ctx, - explicit_only=bool(params.get("explicit_only")), + ctx, explicit_only=bool(params.get("explicit_only")), include_unconfigured=bool(params.get("include_unconfigured")), refresh=bool(params.get("refresh"))) return _ok(rid, payload) diff --git a/tui_gateway/methods_complete_helpers.py b/tui_gateway/methods_complete_helpers.py index b6631ab069..4bb500ef48 100644 --- a/tui_gateway/methods_complete_helpers.py +++ b/tui_gateway/methods_complete_helpers.py @@ -135,14 +135,6 @@ def _abs_completion_prefix_exists(path_part: str) -> bool: return False -def _details_completion_item(value: str, meta: str = "") -> dict: - return {"text": value, "display": value, "meta": meta} - - -def _details_root_completion_item(value: str, meta: str, needs_leading_space: bool) -> dict: - return _details_completion_item(f" {value}" if needs_leading_space else value, meta) - - _DETAILS_SECTIONS = ("thinking", "tools", "subagents", "activity") _DETAILS_MODES = ("hidden", "collapsed", "expanded") @@ -154,39 +146,34 @@ def _details_root_meta(candidate: str) -> str: def _details_completions(text: str) -> list[dict] | None: + """Argument completions for ``/details [section] [mode]``; None when ``text`` is not that command.""" if not text.lower().startswith("/details"): return None stripped = text.strip() if stripped and not "/details".startswith(stripped.lower().split()[0]): return None - body = text[len("/details") :] - if body.startswith(" "): - body = body[1:] + body = text[len("/details") :].removeprefix(" ") parts = body.split() - has_trailing_space = text.endswith(" ") - sections, modes = _DETAILS_SECTIONS, _DETAILS_MODES - root_candidates = (*modes, "cycle", *sections) - if not body or (len(parts) == 0 and has_trailing_space): - return [_details_root_completion_item(c, _details_root_meta(c), not has_trailing_space) for c in root_candidates] - if len(parts) == 1 and not has_trailing_space: + trailing = text.endswith(" ") + root_candidates = (*_DETAILS_MODES, "cycle", *_DETAILS_SECTIONS) + if not body or (not parts and trailing): + lead = "" if trailing else " " + return [_item(f"{lead}{c}", _details_root_meta(c)) for c in root_candidates] + if len(parts) == 1 and not trailing: prefix = parts[0].lower() - return [ - _details_completion_item(c, _details_root_meta(c)) - for c in root_candidates - if c.startswith(prefix) and c != prefix] + return [_item(c, _details_root_meta(c)) for c in root_candidates if c.startswith(prefix) and c != prefix] section = parts[0].lower() if parts else "" - if section not in sections: + if section not in _DETAILS_SECTIONS: return [] def section_meta(candidate: str) -> str: return f"clear {section} override" if candidate == "reset" else f"set {section}" - if len(parts) == 1 and has_trailing_space: - return [_details_completion_item(c, section_meta(c)) for c in (*modes, "reset")] - if len(parts) == 2 and not has_trailing_space: + mode_candidates = (*_DETAILS_MODES, "reset") + if len(parts) == 1: # trailing space after the section + return [_item(c, section_meta(c)) for c in mode_candidates] + if len(parts) == 2 and not trailing: prefix = parts[1].lower() - return [ - _details_completion_item(c, section_meta(c)) for c in (*modes, "reset") if c.startswith(prefix) and c != prefix - ] + return [_item(c, section_meta(c)) for c in mode_candidates if c.startswith(prefix) and c != prefix] return [] diff --git a/tui_gateway/methods_slash.py b/tui_gateway/methods_slash.py index 270da69700..11497865d5 100644 --- a/tui_gateway/methods_slash.py +++ b/tui_gateway/methods_slash.py @@ -16,9 +16,6 @@ _registry = HandlerRegistry() # ── Live-session slash output ──────────────────────────────────────── -_LIVE_SESSION_DIRECT_COMMANDS = frozenset( - {"clear", "compress", "effort", "history", "models", "prompt", "rename", "review", "status", "usage"} -) # Answered from the live session ONLY when the agent lives on a compute host. _ISOLATED_SESSION_READ_COMMANDS = frozenset({"context", "tools", "help"}) @@ -26,7 +23,7 @@ _NO_AGENT_USAGE = "(._.) No active agent -- send a message first." _NO_AGENT = "No active agent -- send a message first." -def _format_live_review_output(session: Optional[dict], arg: str) -> str: +def _format_live_review_output(sid: str, session: Optional[dict], arg: str) -> str: """Dispatch /review against the live session's agent. The reviewer subagent runs on the async delegation rail; the TUI notification @@ -57,7 +54,7 @@ def _format_live_review_output(session: Optional[dict], arg: str) -> str: return format_dispatch_note(result, arg or "") -def _format_live_usage_output(session: dict) -> str: +def _format_live_usage_output(sid: str, session: dict, arg: str) -> str: agent = session.get("agent") usage = _session_usage_snapshot(session) if agent is None and not usage: @@ -105,7 +102,7 @@ def _live_session_messages(session: dict) -> Optional[list]: return None -def _format_live_history_output(session: dict) -> str: +def _format_live_history_output(sid: str, session: dict, arg: str) -> str: with session["history_lock"]: history = list(session.get("history", [])) db_history = _live_session_messages(session) @@ -123,7 +120,7 @@ def _format_live_history_output(session: dict) -> str: return "\n".join(lines) -def _format_live_prompt_output(session: dict) -> str: +def _format_live_prompt_output(sid: str, session: dict, arg: str) -> str: agent = session.get("agent") mirror = _metadata_mirror(session) if agent is None and "system_prompt" not in mirror: @@ -138,7 +135,7 @@ def _format_live_prompt_output(session: dict) -> str: return f"Current system prompt:\n{prompt}" -def _format_live_context_output(session: dict) -> str: +def _format_live_context_output(sid: str, session: dict, arg: str) -> str: try: messages = _history_to_messages(_live_session_messages(session) or []) except Exception: @@ -173,7 +170,7 @@ def _format_live_context_output(session: dict) -> str: return "\n".join(lines) -def _format_live_tools_output(session: dict) -> str: +def _format_live_tools_output(sid: str, session: dict, arg: str) -> str: info = _session_info(session.get("agent"), session) groups = info.get("tools") if isinstance(info, dict) else {} if not isinstance(groups, dict) or not groups: @@ -184,7 +181,7 @@ def _format_live_tools_output(session: dict) -> str: return "Available tools ({}):\n{}".format(len(names), "\n".join(f" {name}" for name in names)) -def _format_live_help_output() -> str: +def _format_live_help_output(sid: str, session: dict, arg: str) -> str: try: from hermes_cli.commands import COMMANDS_BY_CATEGORY lines = ["Available commands:", ""] @@ -205,29 +202,33 @@ def _format_live_model_output(session: dict) -> str: return f"Current model: {model}" if model else "Current model: (unknown)" -def _format_live_status_output(sid: str) -> str: +def _format_live_status_output(sid: str, session: dict, arg: str) -> str: response = _methods["session.status"]("status", {"session_id": sid}) if response.get("error"): return str(response["error"].get("message") or "status unavailable") return str(response.get("result", {}).get("output") or "") -# name → (reply when there is no session, formatter(sid, session, arg)). A None -# no-session reply means the formatter handles a missing session itself. +def _format_live_compress_output(sid: str, session: dict, arg: str) -> str: + return _mirror_slash_side_effects(sid, session, f"/compress {arg}".strip()) + + +# name → (reply when there is no session, formatter(sid, session, arg) or a fixed reply). +# A None no-session reply means the formatter handles a missing session itself. _LIVE_SLASH_OUTPUT = { - "compress": ("no active session for /compress", lambda sid, s, a: _mirror_slash_side_effects(sid, s, f"/compress {a}".strip())), - "usage": (_NO_AGENT_USAGE, lambda sid, s, a: _format_live_usage_output(s)), - "review": (None, lambda sid, s, a: _format_live_review_output(s, a)), - "history": ("No conversation history yet.", lambda sid, s, a: _format_live_history_output(s)), - "prompt": (_NO_AGENT, lambda sid, s, a: _format_live_prompt_output(s)), - "status": (None, lambda sid, s, a: _format_live_status_output(sid)), - "context": ("Conversation is empty (no messages yet).", lambda sid, s, a: _format_live_context_output(s)), - "tools": ("No tools available.", lambda sid, s, a: _format_live_tools_output(s)), - "help": (None, lambda sid, s, a: _format_live_help_output()), - "clear": (None, lambda sid, s, a: "Screen clear is terminal-only; desktop/TUI chat left unchanged."), - "models": (None, lambda sid, s, a: "Use /model to view or switch the current model; desktop users can also open the model picker."), - "rename": (None, lambda sid, s, a: "Use /title to rename this session."), - "effort": (None, lambda sid, s, a: "Use /reasoning to change reasoning effort.")} + "compress": ("no active session for /compress", _format_live_compress_output), + "usage": (_NO_AGENT_USAGE, _format_live_usage_output), + "review": (None, _format_live_review_output), + "history": ("No conversation history yet.", _format_live_history_output), + "prompt": (_NO_AGENT, _format_live_prompt_output), + "status": (None, _format_live_status_output), + "context": ("Conversation is empty (no messages yet).", _format_live_context_output), + "tools": ("No tools available.", _format_live_tools_output), + "help": (None, _format_live_help_output), + "clear": (None, "Screen clear is terminal-only; desktop/TUI chat left unchanged."), + "models": (None, "Use /model to view or switch the current model; desktop users can also open the model picker."), + "rename": (None, "Use /title to rename this session."), + "effort": (None, "Use /reasoning to change reasoning effort.")} def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg: str) -> Optional[str]: @@ -236,10 +237,7 @@ def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg arg = arg or "" if name == "model" and not arg.strip(): return _format_live_model_output(session or {}) - if name in _ISOLATED_SESSION_READ_COMMANDS: - if not (session is not None and _session_uses_compute_host(session)): - return None - elif name not in _LIVE_SESSION_DIRECT_COMMANDS: + if name in _ISOLATED_SESSION_READ_COMMANDS and not (session is not None and _session_uses_compute_host(session)): return None entry = _LIVE_SLASH_OUTPUT.get(name) if entry is None: @@ -247,7 +245,7 @@ def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg no_session_reply, fmt = entry if session is None and no_session_reply is not None: return no_session_reply - return fmt(sid, session, arg) + return fmt(sid, session, arg) if callable(fmt) else fmt # ── Side-effect mirroring ──────────────────────────────────────────── diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 5657537088..65e325e0ca 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -134,19 +134,30 @@ def _clip(text: str, n: int = 120) -> str: return text[:n] + ("…" if len(text) > n else "") +def _exec_out(rid, output: str) -> dict: + """command.dispatch display-only result.""" + return _ok(rid, {"type": "exec", "output": output}) + + def _capture_run_kwargs(timeout: int) -> dict: """subprocess.run kwargs shared by cli.exec / shell.exec / quick commands: captured text, UTF-8 + lossy decode (non-UTF-8 child output must not crash the gateway thread on locale-mismatched Windows), no stdin, no console flash under the desktop parent.""" from hermes_cli._subprocess_compat import windows_hide_flags return dict( - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=timeout, - stdin=subprocess.DEVNULL, - creationflags=windows_hide_flags()) + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, + stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) + + +def _captured_exec(rid, cmd, timeout: int, *, on_result, timeout_err: tuple, fail_code: int, **kw) -> dict: + """Run ``cmd`` captured (see ``_capture_run_kwargs``) and hand the CompletedProcess to + ``on_result``; TimeoutExpired → ``timeout_err`` (code, message), other errors → ``fail_code``.""" + try: + return on_result(subprocess.run(cmd, cwd=os.getcwd(), **kw, **_capture_run_kwargs(timeout))) + except subprocess.TimeoutExpired: + return _err(rid, *timeout_err) + except Exception as e: + return _err(rid, fail_code, str(e)) def _toolset_rows(params: dict, *, with_tools: bool) -> list[dict]: @@ -329,102 +340,119 @@ def _(rid, params: dict) -> dict: # ─── Command catalog / dispatch ────────────────────────────────────────────── +class _Catalog: + """Accumulator for commands.catalog: ``pairs`` (every [key, desc]), ``canon`` (lowercase + key/alias → canonical key), ``commands`` (key → desktop meta) and ordered categories.""" + + def __init__(self) -> None: + self.pairs: list[list[str]] = [] + self.canon: dict[str, str] = {} + self.commands: dict[str, dict[str, str | None]] = {} + self.cat_map: dict[str, list[list[str]]] = {} + self.cat_order: list[str] = [] + + def bucket(self, cat: str) -> list[list[str]]: + if cat not in self.cat_map: + self.cat_map[cat] = [] + self.cat_order.append(cat) + return self.cat_map[cat] + + def add(self, key: str, desc: str, cat: str) -> None: + self.canon[key.lower()] = key + self.pairs.append([key, desc]) + self.bucket(cat).append([key, desc]) + + +def _catalog_registry(cat: _Catalog) -> None: + from hermes_cli.commands import COMMAND_REGISTRY, _build_description, command_desktop_meta + for cmd in COMMAND_REGISTRY: + meta = command_desktop_meta(cmd) + for key in (cmd.name, *cmd.aliases): + cat.commands[f"/{key}"] = dict(meta) + if cmd.name in _TUI_HIDDEN or cmd.gateway_only: + continue + cat.add(f"/{cmd.name}", _build_description(cmd), cmd.category) + for a in cmd.aliases: + cat.canon[f"/{a}".lower()] = f"/{cmd.name}" + for name, desc, category in _TUI_EXTRA: + # Registry command/alias wins over a colliding TUI extra (e.g. /compact, /sessions). + if name.lower() not in cat.canon: + cat.add(name, desc, category) + + +def _catalog_quick_commands(cat: _Catalog) -> None: + qcmds = _load_cfg().get("quick_commands", {}) or {} + if not (isinstance(qcmds, dict) and qcmds): + return + cat.bucket("User commands") # category exists even when every entry is malformed + for qname, qc in sorted(qcmds.items()): + if not isinstance(qc, dict): + continue + qtype = qc.get("type", "") + default_desc = { + "exec": f"exec: {qc.get('command', '')}", "alias": f"alias → {qc.get('target', '')}" + }.get(qtype, qtype or "quick command") + cat.add(f"/{qname}", _clip(str(qc.get("description") or default_desc)), "User commands") + + +def _catalog_plugin_commands(cat: _Catalog) -> None: + from hermes_cli.plugins import get_plugin_commands + plugin_cmds = get_plugin_commands() or {} + if plugin_cmds: + cat.bucket("Plugin commands") + for pname, info in sorted(plugin_cmds.items()): + key = f"/{pname}" + if not isinstance(info, dict) or key.lower() in cat.canon: + continue + cat.add(key, _clip(str(info.get("description") or "Plugin command")), "Plugin commands") + mode = info.get("argument_mode") + if mode not in {"options", "text", "mixed"}: + mode = "text" if str(info.get("args_hint") or "").strip() else None + cat.commands[key] = {"argument_mode": mode, "desktop": None} + + +def _catalog_skills(cat: _Catalog, skills: dict[str, dict]) -> None: + """Append skill pairs and fill ``skills`` = ``{key: {usage, origin}}`` (usage + origin ride + along — not a second RPC — because every catalog consumer also ranks by them).""" + from agent.skill_commands import scan_skill_commands + usage, origin_of = _skill_usage_lookup() + for k, info in sorted(scan_skill_commands().items()): + cat.pairs.append([k, _clip(str(info.get("description", "Skill")))]) + name = str(info.get("name") or k.lstrip("/")) + skills[k] = {"usage": usage(name), "origin": origin_of(name)} + + @method("commands.catalog") @_guarded(5020) def _(rid, params: dict) -> dict: - """Registry-backed slash metadata for the TUI — categorized, no aliases.""" - from hermes_cli.commands import COMMAND_REGISTRY, SUBCOMMANDS, _build_description, command_desktop_meta - all_pairs: list[list[str]] = [] - canon: dict[str, str] = {} - commands: dict[str, dict[str, str | None]] = {} - cat_map: dict[str, list[list[str]]] = {} - cat_order: list[str] = [] - - def bucket(cat: str) -> list[list[str]]: - if cat not in cat_map: - cat_map[cat] = [] - cat_order.append(cat) - return cat_map[cat] - - def add(key: str, desc: str, rows: list[list[str]]) -> None: - canon[key.lower()] = key - all_pairs.append([key, desc]) - rows.append([key, desc]) - for cmd in COMMAND_REGISTRY: - meta = command_desktop_meta(cmd) - commands[f"/{cmd.name}"] = dict(meta) - for alias in cmd.aliases: - commands[f"/{alias}"] = dict(meta) - if cmd.name in _TUI_HIDDEN or cmd.gateway_only: - continue - c = f"/{cmd.name}" - add(c, _build_description(cmd), bucket(cmd.category)) - for a in cmd.aliases: - canon[f"/{a}".lower()] = c - for name, desc, cat in _TUI_EXTRA: - # Registry command/alias wins over a colliding TUI extra (e.g. /compact, /sessions). - if name.lower() not in canon: - add(name, desc, bucket(cat)) + """Registry-backed slash metadata for the TUI — categorized, no aliases. Discovery + failures land in ``warning`` (skills' message wins, then quick commands', then plugins').""" + from hermes_cli.commands import SUBCOMMANDS + cat = _Catalog() + _catalog_registry(cat) warning = "" try: - qcmds = _load_cfg().get("quick_commands", {}) or {} - if isinstance(qcmds, dict) and qcmds: - rows = bucket("User commands") - for qname, qc in sorted(qcmds.items()): - if not isinstance(qc, dict): - continue - qtype = qc.get("type", "") - default_desc = { - "exec": f"exec: {qc.get('command', '')}", - "alias": f"alias → {qc.get('target', '')}", - }.get(qtype, qtype or "quick command") - add(f"/{qname}", _clip(str(qc.get("description") or default_desc)), rows) + _catalog_quick_commands(cat) except Exception as e: warning = f"quick_commands discovery unavailable: {e}" try: - from hermes_cli.plugins import get_plugin_commands - plugin_cmds = get_plugin_commands() or {} - if plugin_cmds: - rows = bucket("Plugin commands") - for pname, info in sorted(plugin_cmds.items()): - if not isinstance(info, dict): - continue - key = f"/{pname}" - if key.lower() in canon: - continue - add(key, _clip(str(info.get("description") or "Plugin command")), rows) - hint = str(info.get("args_hint") or "").strip() - mode = info.get("argument_mode") - if mode not in {"options", "text", "mixed"}: - mode = "text" if hint else None - commands[key] = {"argument_mode": mode, "desktop": None} + _catalog_plugin_commands(cat) except Exception as e: - if not warning: - warning = f"plugin command discovery unavailable: {e}" - skill_count = 0 + warning = warning or f"plugin command discovery unavailable: {e}" skills: dict[str, dict] = {} try: - from agent.skill_commands import scan_skill_commands - - # Usage + origin ride along (not a second RPC): every catalog consumer also ranks it. - usage, origin_of = _skill_usage_lookup() - for k, info in sorted(scan_skill_commands().items()): - all_pairs.append([k, _clip(str(info.get("description", "Skill")))]) - name = str(info.get("name") or k.lstrip("/")) - skills[k] = {"usage": usage(name), "origin": origin_of(name)} - skill_count += 1 + _catalog_skills(cat, skills) except Exception as e: warning = f"skill discovery unavailable: {e}" - payload = { - "pairs": all_pairs, + return _ok(rid, { + "pairs": cat.pairs, "sub": {k: v[:] for k, v in SUBCOMMANDS.items()}, - "canon": canon, - "commands": commands, - "categories": [{"name": cat, "pairs": cat_map[cat]} for cat in cat_order], + "canon": cat.canon, + "commands": cat.commands, + "categories": [{"name": c, "pairs": cat.cat_map[c]} for c in cat.cat_order], "skills": skills, - "skill_count": skill_count, - "warning": warning} - return _ok(rid, payload) + "skill_count": len(skills), + "warning": warning}) @method("cli.exec") @@ -436,20 +464,16 @@ def _(rid, params: dict) -> dict: hint = _cli_exec_blocked(argv) if hint: return _ok(rid, {"blocked": True, "hint": hint, "code": -1, "output": ""}) - try: - r = subprocess.run( - [sys.executable, "-m", "hermes_cli.main", *argv], - cwd=os.getcwd(), - # Can drive the agent → needs provider credentials; tier-1 secrets still stripped. - env=hermes_subprocess_env(inherit_credentials=True), - **_capture_run_kwargs(min(int(params.get("timeout", 240)), 600))) - parts = [r.stdout or "", r.stderr or ""] - out = "\n".join(p for p in parts if p).strip() or "(no output)" + + def done(r): + out = "\n".join(p for p in (r.stdout or "", r.stderr or "") if p).strip() or "(no output)" return _ok(rid, {"blocked": False, "code": r.returncode, "output": out[:48_000]}) - except subprocess.TimeoutExpired: - return _err(rid, 5016, "cli.exec: timeout") - except Exception as e: - return _err(rid, 5017, str(e)) + + # Can drive the agent → needs provider credentials; tier-1 secrets still stripped. + return _captured_exec( + rid, [sys.executable, "-m", "hermes_cli.main", *argv], min(int(params.get("timeout", 240)), 600), + on_result=done, timeout_err=(5016, "cli.exec: timeout"), fail_code=5017, + env=hermes_subprocess_env(inherit_credentials=True)) @method("command.resolve") @@ -482,7 +506,7 @@ def _dispatch_quick(rid, params, session, name, arg): output = redact_sensitive_text(output) if r.returncode != 0: return _err(rid, 4018, output or f"quick command failed with exit code {r.returncode}") - return _ok(rid, {"type": "exec", "output": output}) + return _exec_out(rid, output) if qc.get("type") == "alias": return _ok(rid, {"type": "alias", "target": qc.get("target", "")}) return None @@ -544,9 +568,7 @@ def _dispatch_bundle(rid, params, session, name, arg): from agent.skill_bundles import build_bundle_invocation_message, get_skill_bundles try: bundle_result = build_bundle_invocation_message( - bundle_key, - arg, - task_id=session.get("session_key", "") if session else "", + bundle_key, arg, task_id=session.get("session_key", "") if session else "", platform=_resolve_session_platform()) except Exception as exc: return _err(rid, 4018, f"bundle dispatch failed: {exc}") @@ -588,19 +610,17 @@ def _cmd_queue(rid, params, session, name, arg): def _cmd_learn(rid, params, session, name, arg): - # Submitted as a normal turn; the live agent gathers sources and authors the skill via skill_manage. + # Normal turn: the live agent gathers sources and authors the skill via skill_manage. from agent.learn_prompt import build_learn_prompt return _ok(rid, {"type": "send", "message": build_learn_prompt(arg)}) def _cmd_plan(rid, params, session, name, arg): - # Normal turn (as /learn); the agent saves the plan under .hermes/plans/ via write_file. from agent.plan_prompt import build_plan_prompt return _ok(rid, {"type": "send", "message": build_plan_prompt(arg)}) def _cmd_init(rid, params, session, name, arg): - # Generate-or-update AGENTS.md as a normal turn (as /learn). from hermes_cli.init_command import build_init_prompt_for_cwd return _ok(rid, {"type": "send", "message": build_init_prompt_for_cwd(extra=arg)}) @@ -621,29 +641,22 @@ def _cmd_moa(rid, params, session, name, arg): agent = session.get("agent") session["moa_one_shot_restore"] = { "override": session.get("model_override"), - "model": getattr(agent, "model", None) if agent else None, - "provider": getattr(agent, "provider", None) if agent else None} + "model": getattr(agent, "model", None), + "provider": getattr(agent, "provider", None)} if agent is not None: try: + # persist_override=False: turn-scoped, never persist the MoA provider to config.yaml _apply_model_switch( - sid, - session, - f"{preset} --provider moa", - confirm_expensive_model=False, - pin_session_override=True, - persist_override=False, # turn-scoped: never persist the MoA provider to config.yaml - ) + sid, session, f"{preset} --provider moa", confirm_expensive_model=False, + pin_session_override=True, persist_override=False) except Exception as exc: session.pop("moa_one_shot_restore", None) return _err(rid, 5030, f"moa unavailable: {exc}") else: # Lazy/fresh session: the override is consumed by the first build. session["model_override"] = { - "provider": "moa", - "model": preset, - "base_url": "moa://local", - "api_key": "moa-virtual-provider", - "api_mode": "chat_completions"} + "provider": "moa", "model": preset, "base_url": "moa://local", + "api_key": "moa-virtual-provider", "api_mode": "chat_completions"} notice = f"MoA one-shot queued with preset {preset}; previous model will be restored after this turn." return _ok(rid, {"type": "send", "notice": notice, "message": arg}) except Exception as exc: @@ -661,14 +674,14 @@ def _cmd_focus(rid, params, session, name, arg): return _err(rid, 4004, "usage: /focus [on|off|status]") if action == "status": saved = display.get("focus_saved_tool_progress") or _load_tool_progress_mode() - return _ok(rid, {"type": "exec", "output": format_focus_status(cur, saved)}) + return _exec_out(rid, format_focus_status(cur, saved)) res = _methods["config.set"]( rid, {"key": "focus", "value": "on" if target else "off", "session_id": params.get("session_id", "")} ) if "error" in res: return res output = format_focus_toggle_message(bool(target), (res.get("result") or {}).get("tool_progress") or "all") - return _ok(rid, {"type": "exec", "output": output}) + return _exec_out(rid, output) def _cmd_retry(rid, params, session, name, arg): @@ -709,11 +722,10 @@ def _cmd_steer(rid, params, session, name, arg): try: if agent.steer(arg): shown = f"{arg[:80]}{'...' if len(arg) > 80 else ''}" - return _ok(rid, {"type": "exec", "output": f"⏩ Steer queued — arrives after the next tool call: {shown}"}) + return _exec_out(rid, f"⏩ Steer queued — arrives after the next tool call: {shown}") except Exception: pass - # No active run: treat as next-turn message. - return _ok(rid, {"type": "send", "message": arg}) + return _ok(rid, {"type": "send", "message": arg}) # no active run: next-turn message def _cmd_goal(rid, params, session, name, arg): @@ -731,26 +743,26 @@ def _cmd_goal(rid, params, session, name, arg): mgr = GoalManager(session_id=sid_key, default_max_turns=max_turns) lower = arg.strip().lower() if not arg.strip() or lower == "status": - return _ok(rid, {"type": "exec", "output": mgr.status_line()}) + return _exec_out(rid, mgr.status_line()) if lower == "pause": state = mgr.pause(reason="user-paused") out = "No goal set." if state is None else f"⏸ Goal paused: {state.goal}" - return _ok(rid, {"type": "exec", "output": out}) + return _exec_out(rid, out) if lower == "resume": state = mgr.resume() if state is None: - return _ok(rid, {"type": "exec", "output": "No goal to resume."}) + return _exec_out(rid, "No goal to resume.") # Resume must restart work: `exec` is display-only, so return a `send` with the # continuation prompt; `display` keeps model-facing scaffolding out of the transcript. prompt = mgr.next_continuation_prompt() if not prompt: - return _ok(rid, {"type": "exec", "output": f"▶ Goal resumed: {state.goal}"}) + return _exec_out(rid, f"▶ Goal resumed: {state.goal}") notice = f"▶ Goal resumed: {state.goal}\nContinuing now — taking the next step." return _ok(rid, {"type": "send", "notice": notice, "message": prompt, "display": "/goal resume"}) if lower in {"clear", "stop", "done"}: had = mgr.has_goal() mgr.clear() - return _ok(rid, {"type": "exec", "output": "✓ Goal cleared." if had else "No active goal."}) + return _exec_out(rid, "✓ Goal cleared." if had else "No active goal.") # Remaining text = new goal. Client renders `notice`, submits `message`; the post-turn judge takes over. try: @@ -765,7 +777,6 @@ def _cmd_goal(rid, params, session, name, arg): def _cmd_loop(rid, params, session, name, arg): - # Recurring in-session wakeups; the notification poller fires due ones while the session is idle. sid_key, err = _session_key_or_err(rid, session) if err: return err @@ -782,11 +793,10 @@ def _cmd_loop(rid, params, session, name, arg): output += ( "\nNote: an active /goal is driving this session — loop " "wakeups defer until the goal finishes, pauses, or parks.") - return _ok(rid, {"type": "exec", "output": output}) + return _exec_out(rid, output) def _cmd_undo(rid, params, session, name, arg): - # /undo [N]: back up N user turns, soft-delete truncated rows on disk, prefill the composer. if not session: return _err(rid, 4001, "no active session to undo") if busy := _busy_error(rid, session, "undo"): @@ -843,7 +853,7 @@ def _cmd_snapshot(rid, params, session, name, arg): "/snapshot restore is blocked in the TUI because it changes config/state on disk " "while the live agent has cached settings. Run it in the classic CLI, then restart the TUI." ) - return _ok(rid, {"type": "exec", "output": output}) + return _exec_out(rid, output) def _cmd_compress(rid, params, session, name, arg): @@ -861,13 +871,12 @@ def _cmd_compress(rid, params, session, name, arg): return _ok(rid, payload) try: output = _compress_live_with_feedback(sid, session, session["agent"], arg, snapshot_kwargs=True) - return _ok(rid, {"type": "exec", "output": output}) + return _exec_out(rid, output) except Exception as exc: finalize_context_engine_compression_notification(session["agent"], committed=False) return _err(rid, 5009, f"compress failed: {exc}") -# name → built-in handler (values are rebound onto server globals by bind_module). _SLASH_BUILTINS = { "queue": _cmd_queue, "q": _cmd_queue, "learn": _cmd_learn, "plan": _cmd_plan, "init": _cmd_init, "moa": _cmd_moa, "focus": _cmd_focus, "retry": _cmd_retry, "steer": _cmd_steer, "goal": _cmd_goal, @@ -1189,9 +1198,7 @@ def _(rid, params: dict) -> dict: if action == "add": # Optional repeat / continuity / deliver ('bot-chat[:name]'): None keeps each cronjob() default. raw = cronjob( - action="create", - name=jid, - schedule=params.get("schedule", ""), + action="create", name=jid, schedule=params.get("schedule", ""), prompt=params.get("prompt", ""), repeat=int(params["repeat"]) if str(params.get("repeat", "")).strip().isdigit() else None, continuity=is_truthy_value(params.get("continuity")) if params.get("continuity") is not None else None, @@ -1236,6 +1243,11 @@ for _rpc, _fn, _keys in ( del _rpc, _fn, _keys +class _QuietConsole: + def print(self, *a, **k): + pass + + def _skills_list(rid, params, query): from hermes_cli.banner import get_available_skills return _ok(rid, {"skills": get_available_skills()}) @@ -1249,11 +1261,7 @@ def _skills_search(rid, params, query): def _skills_install(rid, params, query): from hermes_cli.skills_hub import do_install - - class _Q: - def print(self, *a, **k): - pass - do_install(query, skip_confirm=True, console=_Q()) + do_install(query, skip_confirm=True, console=_QuietConsole()) return _ok(rid, {"installed": True, "name": query}) @@ -1268,21 +1276,20 @@ def _skills_inspect(rid, params, query): return _ok(rid, {"info": inspect_skill(query) or {}}) +_SKILLS_ACTIONS = { + "list": _skills_list, "search": _skills_search, "install": _skills_install, "browse": _skills_browse, + "inspect": _skills_inspect} + + @method("skills.manage") @_profile_scoped_rpc(5024) def _(rid, params: dict) -> dict: """list/install use the scoped profile's skills dir; search/browse/inspect hit the shared hub.""" - action, query = params.get("action", "list"), params.get("query", "") - handler = { - "list": _skills_list, - "search": _skills_search, - "install": _skills_install, - "browse": _skills_browse, - "inspect": _skills_inspect, - }.get(action) + action = params.get("action", "list") + handler = _SKILLS_ACTIONS.get(action) if handler is None: return _err(rid, 4017, f"unknown skills action: {action}") - return handler(rid, params, query) + return handler(rid, params, params.get("query", "")) @method("skills.reload") @@ -1321,14 +1328,13 @@ def _(rid, params: dict) -> dict: except Exception: requires = [] transport = getattr(entry, "transport", None) # TransportSpec → its kind string - out.append( - { - "name": entry.name, - "description": getattr(entry, "description", "") or "", - "installed": bool(mcp_catalog.is_installed(entry.name)), - "enabled": bool(mcp_catalog.is_enabled(entry.name)), - "requires": requires, - "transport": str(getattr(transport, "kind", "") or transport or "stdio")}) + out.append({ + "name": entry.name, + "description": getattr(entry, "description", "") or "", + "installed": bool(mcp_catalog.is_installed(entry.name)), + "enabled": bool(mcp_catalog.is_enabled(entry.name)), + "requires": requires, + "transport": str(getattr(transport, "kind", "") or transport or "stdio")}) return _ok(rid, {"servers": out}) @@ -1357,11 +1363,8 @@ def _(rid, params: dict) -> dict: server_config: dict = dict(raw_cfg) if isinstance(raw_cfg, dict) else {} if preset: # fills url/command/args when omitted; mutates server_config in place _apply_mcp_preset( - name, - preset_name=preset, - url=server_config.get("url"), - command=server_config.get("command"), - cmd_args=list(server_config.get("args") or []), + name, preset_name=preset, url=server_config.get("url"), + command=server_config.get("command"), cmd_args=list(server_config.get("args") or []), server_config=server_config) if not server_config.get("url") and not server_config.get("command"): return _err(rid, 4063, "config must specify a 'url' (http) or 'command' (stdio), or a valid 'preset'") @@ -1402,10 +1405,8 @@ def _(rid, params: dict) -> dict: else: save_env_value(env_var, str(value)) env_block = entry.get("env") - if not isinstance(env_block, dict): - env_block = {} + entry["env"] = env_block = env_block if isinstance(env_block, dict) else {} env_block[env_var] = f"${{{env_var}}}" - entry["env"] = env_block cfg = load_config() cfg.setdefault("mcp_servers", {})[name] = entry save_config(cfg) @@ -1489,15 +1490,18 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"ok": True, "session_id": result["session_id"], "auth_url": result["auth_url"], "flow": result["flow"]}) +def _oauth_flow_ids(params: dict) -> tuple[str, str]: + """(session_id, name) as stripped strings.""" + return str(params.get("session_id") or "").strip(), str(params.get("name") or "").strip() + + @method("mcp.servers.oauth.poll") @_profile_scoped_rpc(5024, required=_NAME_SESSION, catch_resolve=False) def _(rid, params: dict) -> dict: """Poll a flow → ``{ok, status: pending|approved|error, error_message?, auth_url?, tools?}``. On ``approved`` tokens persist for that server/profile (profile scope applies here too).""" from tui_gateway import mcp_oauth_sessions - name = str(params.get("name") or "").strip() - session_id = str(params.get("session_id") or "").strip() - result = mcp_oauth_sessions.poll_flow(session_id, name) + result = mcp_oauth_sessions.poll_flow(*_oauth_flow_ids(params)) return _ok(rid, {"ok": True, **result}) @@ -1507,15 +1511,9 @@ def _(rid, params: dict) -> dict: """Relay a client-captured redirect (``code``/``state``/``error``) into a flow started with ``client_redirect_uri``. ``{ok: true}`` once accepted (state verified), else ``{ok: false, error_message}``.""" from tui_gateway import mcp_oauth_sessions - name = str(params.get("name") or "").strip() - session_id = str(params.get("session_id") or "").strip() - result = mcp_oauth_sessions.deliver_callback_flow( - session_id, - name, - code=str(params.get("code") or "") or None, - state=str(params.get("state") or "") or None, - error=str(params.get("error") or "") or None) - return _ok(rid, result) + code, state, error = (str(params.get(k) or "") or None for k in ("code", "state", "error")) + session_id, name = _oauth_flow_ids(params) + return _ok(rid, mcp_oauth_sessions.deliver_callback_flow(session_id, name, code=code, state=state, error=error)) # ─── Plugins ───────────────────────────────────────────────────────────────── @@ -1523,12 +1521,8 @@ def _(rid, params: dict) -> dict: def _plugin_rows() -> list[dict]: from hermes_cli.plugins_cmd import ( - _bundled_default_on, - _discover_all_plugins, - _get_disabled_set, - _get_enabled_set, - _is_portable_plugin_dir, - _plugin_status) + _bundled_default_on, _discover_all_plugins, _get_disabled_set, _get_enabled_set, + _is_portable_plugin_dir, _plugin_status) enabled = _get_enabled_set() disabled = _get_disabled_set() out = [] @@ -1538,16 +1532,11 @@ def _plugin_rows() -> list[dict]: # truthful default instead of "not enabled" (reads as OFF). if status == "not enabled" and source == "bundled" and _bundled_default_on(_dir): status = "enabled" - out.append( - { - "name": name, - "key": key, # canonical registry key (``image_gen/fal``): names collide across category dirs - "version": str(version or ""), - "description": desc or "", - "source": source, - "status": status, - "portable": _is_portable_plugin_dir(_dir), # Agent Plugins v1 package vs native Hermes plugin - }) + # key = canonical registry key (``image_gen/fal``; names collide across category dirs); + # portable = Agent Plugins v1 package vs native Hermes plugin. + out.append({ + "name": name, "key": key, "version": str(version or ""), "description": desc or "", + "source": source, "status": status, "portable": _is_portable_plugin_dir(_dir)}) return out @@ -1582,6 +1571,9 @@ def _plugins_install(rid, params): return _ok(rid, result) +_PLUGINS_ACTIONS = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install} + + @method("plugins.manage") @_profile_scoped_rpc(5026, catch_resolve=False) def _(rid, params: dict) -> dict: @@ -1591,7 +1583,7 @@ def _(rid, params: dict) -> dict: - ``install`` → git-clone ``identifier``/``repo`` into ~/.hermes/plugins/ (``force``, ``enable`` default True) Optional ``profile`` scopes HERMES_HOME (mcp.servers.* contract).""" action = params.get("action", "list") - handler = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install}.get(action) + handler = _PLUGINS_ACTIONS.get(action) if handler is None: return _err(rid, 4017, f"unknown plugins action: {action}") return handler(rid, params) @@ -1612,13 +1604,9 @@ def _(rid, params: dict) -> dict: return _err(rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands.") except ImportError: return _err(rid, 5001, "shell.exec unavailable: approval safety module not importable") - try: - r = subprocess.run(cmd, shell=True, cwd=os.getcwd(), **_capture_run_kwargs(30)) - return _ok(rid, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode}) - except subprocess.TimeoutExpired: - return _err(rid, 5002, "command timed out (30s)") - except Exception as e: - return _err(rid, 5003, str(e)) + return _captured_exec( + rid, cmd, 30, shell=True, fail_code=5003, timeout_err=(5002, "command timed out (30s)"), + on_result=lambda r: _ok(rid, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode})) def register(server) -> None: From 255654b473462869e56634b5b7987ffa11086899 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:33:29 -0700 Subject: [PATCH 3/7] refactor(tui_gateway): usage formatter row table, catalog bucket via setdefault, mirror_model one-liner --- tui_gateway/methods_slash.py | 31 +++++++++++-------------------- tui_gateway/methods_tools.py | 10 +++------- 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/tui_gateway/methods_slash.py b/tui_gateway/methods_slash.py index 11497865d5..fca69349f4 100644 --- a/tui_gateway/methods_slash.py +++ b/tui_gateway/methods_slash.py @@ -67,25 +67,18 @@ def _format_live_usage_output(sid: str, session: dict, arg: str) -> str: def n(key: str) -> str: return f"{int(usage.get(key) or 0):,}" - lines = [ - "Session Token Usage", - "────────────────────────────────────────", - f"Model: {usage.get('model') or _metadata_mirror(session).get('model') or getattr(agent, 'model', '') or '(unknown)'}", - f"Input tokens: {n('input')}", - f"Output tokens: {n('output')}"] + rows = [("Input tokens:", n("input")), ("Output tokens:", n("output"))] if int(usage.get("reasoning") or 0): - lines.append(f"Reasoning tokens: {n('reasoning')}") - lines += [ - f"Prompt tokens: {n('prompt')}", - f"Completion tokens: {n('completion')}", - f"Total tokens: {n('total')}", - f"API calls: {n('calls')}"] + rows.append(("Reasoning tokens:", n("reasoning"))) + rows += [("Prompt tokens:", n("prompt")), ("Completion tokens:", n("completion")), + ("Total tokens:", n("total")), ("API calls:", n("calls"))] if usage.get("context_max"): - lines.append( - f"Current context: {n('context_used')} / {n('context_max')} " - f"({int(usage.get('context_percent') or 0)}%)") - lines += [f"Messages: {message_count:,}", f"Compressions: {n('compressions')}"] - return "\n".join(lines) + pct = int(usage.get("context_percent") or 0) + rows.append(("Current context:", f"{n('context_used')} / {n('context_max')} ({pct}%)")) + rows += [("Messages:", f"{message_count:,}"), ("Compressions:", n("compressions"))] + model = usage.get("model") or _metadata_mirror(session).get("model") or getattr(agent, "model", "") or "(unknown)" + lines = ["Session Token Usage", "────────────────────────────────────────", f"Model: {model}"] + return "\n".join(lines + [f"{label:<30}{value}" for label, value in rows]) def _live_session_messages(session: dict) -> Optional[list]: @@ -305,9 +298,7 @@ def _compress_live_with_feedback(sid: str, session: dict, agent, arg: str, *, sn def _mirror_model(sid, session, agent, arg) -> str: - if arg and agent: - return _apply_model_switch(sid, session, arg).get("warning", "") - return "" + return _apply_model_switch(sid, session, arg).get("warning", "") if arg and agent else "" def _mirror_approvals(sid, session, agent, arg) -> str: diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 65e325e0ca..a1b9eaf88c 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -348,14 +348,10 @@ class _Catalog: self.pairs: list[list[str]] = [] self.canon: dict[str, str] = {} self.commands: dict[str, dict[str, str | None]] = {} - self.cat_map: dict[str, list[list[str]]] = {} - self.cat_order: list[str] = [] + self.cat_map: dict[str, list[list[str]]] = {} # insertion order = category order def bucket(self, cat: str) -> list[list[str]]: - if cat not in self.cat_map: - self.cat_map[cat] = [] - self.cat_order.append(cat) - return self.cat_map[cat] + return self.cat_map.setdefault(cat, []) def add(self, key: str, desc: str, cat: str) -> None: self.canon[key.lower()] = key @@ -449,7 +445,7 @@ def _(rid, params: dict) -> dict: "sub": {k: v[:] for k, v in SUBCOMMANDS.items()}, "canon": cat.canon, "commands": cat.commands, - "categories": [{"name": c, "pairs": cat.cat_map[c]} for c in cat.cat_order], + "categories": [{"name": c, "pairs": rows} for c, rows in cat.cat_map.items()], "skills": skills, "skill_count": len(skills), "warning": warning}) From 8b25a8ae096b25fe3a1806eb3c770b70bc2b636f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:37:54 -0700 Subject: [PATCH 4/7] refactor(tui_gateway): insights.get guard, undo count parse, quick-command output join, Counter role tally --- tui_gateway/methods_slash.py | 10 +++------- tui_gateway/methods_tools.py | 23 +++++++++-------------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/tui_gateway/methods_slash.py b/tui_gateway/methods_slash.py index fca69349f4..41b0af0eab 100644 --- a/tui_gateway/methods_slash.py +++ b/tui_gateway/methods_slash.py @@ -87,11 +87,9 @@ def _live_session_messages(session: dict) -> Optional[list]: profile's state.db, and through the launch handle this read comes back empty.""" with _session_db(session) as db: if db is not None and session.get("session_key"): - try: + with contextlib.suppress(Exception): return db.get_messages_as_conversation( session["session_key"], include_ancestors=True, include_row_ids=True) - except Exception: - pass return None @@ -129,6 +127,7 @@ def _format_live_prompt_output(sid: str, session: dict, arg: str) -> str: def _format_live_context_output(sid: str, session: dict, arg: str) -> str: + from collections import Counter try: messages = _history_to_messages(_live_session_messages(session) or []) except Exception: @@ -139,10 +138,7 @@ def _format_live_context_output(sid: str, session: dict, arg: str) -> str: usage = _session_usage_snapshot(session) mirror = _metadata_mirror(session) lines = [f"Conversation: {len(messages)} messages" if messages else "Conversation is empty (no messages yet)."] - roles: dict[str, int] = {} - for msg in messages: - role = str(msg.get("role") or "unknown") - roles[role] = roles.get(role, 0) + 1 + roles = Counter(str(msg.get("role") or "unknown") for msg in messages) lines.append( f" user: {roles.get('user', 0)}, assistant: {roles.get('assistant', 0)}, " f"tool: {roles.get('tool', 0)}, system: {roles.get('system', 0)}") diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index a1b9eaf88c..26cb19433b 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -496,7 +496,7 @@ def _dispatch_quick(rid, params, session, name, arg): from tools.environments.local import build_subprocess_env sanitized_env = build_subprocess_env() r = subprocess.run(qc.get("command", ""), shell=True, env=sanitized_env, **_capture_run_kwargs(30)) - output = ((r.stdout or "") + ("\n" if r.stdout and r.stderr else "") + (r.stderr or "")).strip()[:4000] + output = "\n".join(p for p in (r.stdout or "", r.stderr or "") if p).strip()[:4000] if output: from agent.redact import redact_sensitive_text output = redact_sensitive_text(output) @@ -800,14 +800,11 @@ def _cmd_undo(rid, params, session, name, arg): session_key = session.get("session_key", "") if not session_key: return _err(rid, 4001, "no session key for undo") - n = 1 arg_str = (arg or "").strip() - if arg_str: - try: - n = int(arg_str.split()[0]) - except (ValueError, IndexError): - return _err(rid, 4004, f"undo: invalid count {arg_str!r} — use /undo or /undo N") - n = max(n, 1) + try: + n = max(int(arg_str.split()[0]), 1) if arg_str else 1 + except (ValueError, IndexError): + return _err(rid, 4004, f"undo: invalid count {arg_str!r} — use /undo or /undo N") from agent.message_content import flatten_message_text with session["history_lock"]: if busy := _busy_error(rid, session, "undo"): @@ -971,17 +968,15 @@ def _(rid, params: dict) -> dict: @method("insights.get") +@_guarded(5017) def _(rid, params: dict) -> dict: days = params.get("days", 30) db = _get_db() if db is None: return _db_unavailable_error(rid, code=5017) - try: - cutoff = time.time() - days * 86400 - rows = [s for s in db.list_sessions_rich(limit=500, compact_rows=True) if (s.get("started_at") or 0) >= cutoff] - return _ok(rid, {"days": days, "sessions": len(rows), "messages": sum(s.get("message_count", 0) for s in rows)}) - except Exception as e: - return _err(rid, 5017, str(e)) + cutoff = time.time() - days * 86400 + rows = [s for s in db.list_sessions_rich(limit=500, compact_rows=True) if (s.get("started_at") or 0) >= cutoff] + return _ok(rid, {"days": days, "sessions": len(rows), "messages": sum(s.get("message_count", 0) for s in rows)}) @method("rollback.list") From 37e60502c12c22b5e9f48da3c88be70cb628c595 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:42:22 -0700 Subject: [PATCH 5/7] refactor(tui_gateway): /learn,/plan,/init via one prompt-builtin factory; mcp.servers.test failure payload inline --- tui_gateway/methods_tools.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 26cb19433b..9d9e1b5e47 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -605,20 +605,20 @@ def _cmd_queue(rid, params, session, name, arg): return _ok(rid, {"type": "send", "message": arg}) -def _cmd_learn(rid, params, session, name, arg): - # Normal turn: the live agent gathers sources and authors the skill via skill_manage. - from agent.learn_prompt import build_learn_prompt - return _ok(rid, {"type": "send", "message": build_learn_prompt(arg)}) +def _prompt_builtin(module: str, fn: str, kw: str = ""): + """/learn, /plan, /init: submit ``module.fn(arg)`` as a normal turn (the live agent does the + work — authors the skill via skill_manage, saves the plan, generates AGENTS.md).""" + + def cmd(rid, params, session, name, arg): + import importlib + build = getattr(importlib.import_module(module), fn) + return _ok(rid, {"type": "send", "message": build(**{kw: arg}) if kw else build(arg)}) + return cmd -def _cmd_plan(rid, params, session, name, arg): - from agent.plan_prompt import build_plan_prompt - return _ok(rid, {"type": "send", "message": build_plan_prompt(arg)}) - - -def _cmd_init(rid, params, session, name, arg): - from hermes_cli.init_command import build_init_prompt_for_cwd - return _ok(rid, {"type": "send", "message": build_init_prompt_for_cwd(extra=arg)}) +_cmd_learn = _prompt_builtin("agent.learn_prompt", "build_learn_prompt") +_cmd_plan = _prompt_builtin("agent.plan_prompt", "build_plan_prompt") +_cmd_init = _prompt_builtin("hermes_cli.init_command", "build_init_prompt_for_cwd", kw="extra") def _cmd_moa(rid, params, session, name, arg): @@ -1421,8 +1421,8 @@ def _(rid, params: dict) -> dict: details: dict = {} def failure(error: str, oauth_needed: bool, tokens_present) -> dict: - payload = {"ok": False, "error": error, "tools": [], "oauth_needed": oauth_needed} - return _ok(rid, {**payload, "oauth_tokens_present": tokens_present}) + return _ok(rid, {"ok": False, "error": error, "tools": [], "oauth_needed": oauth_needed, + "oauth_tokens_present": tokens_present}) try: tools = _probe_single_server(name, cfg, details=details) token_present = _oauth_tokens_present(name) if needs_oauth_token else True From af0eaf691c4ce13cdaffa2afd8825aa54e59c702 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:44:33 -0700 Subject: [PATCH 6/7] refactor(tui_gateway): _list_repo_files split into git/walk generators with islice cap --- tui_gateway/methods_complete_helpers.py | 76 +++++++++++++------------ 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/tui_gateway/methods_complete_helpers.py b/tui_gateway/methods_complete_helpers.py index 4bb500ef48..6fd005dc38 100644 --- a/tui_gateway/methods_complete_helpers.py +++ b/tui_gateway/methods_complete_helpers.py @@ -24,6 +24,42 @@ _fuzzy_cache_lock = threading.Lock() _fuzzy_cache: dict[str, tuple[float, list[str]]] = {} +def _git_repo_files(root: str): + """Yield ``git ls-files`` paths (tracked + untracked) relative to ``root``; empty outside a + repo or on git failure/timeout. Entries above ``root`` are skipped (Cmd-P workspace scope).""" + from hermes_cli._subprocess_compat import windows_hide_flags + run_kw = dict(capture_output=True, timeout=2.0, check=False, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) + try: + top_result = subprocess.run(["git", "-C", root, "rev-parse", "--show-toplevel"], **run_kw) + if top_result.returncode != 0: + return + top = top_result.stdout.decode("utf-8", "replace").strip() + list_result = subprocess.run( + ["git", "-C", top, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], **run_kw) + if list_result.returncode != 0: + return + except (OSError, subprocess.TimeoutExpired): + return + for p in list_result.stdout.decode("utf-8", "replace").split("\0"): + if p: + rel = os.path.relpath(os.path.join(top, p), root).replace(os.sep, "/") + if not rel.startswith("../"): + yield rel + + +def _walk_repo_files(root: str): + """Non-git fallback: ``os.walk`` skipping vendor/build dirs + dot-dirs; dotfiles survive + (the ranker decides based on whether the query starts with `.`).""" + try: + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + dirnames[:] = [d for d in dirnames if d not in _FUZZY_FALLBACK_EXCLUDES and not d.startswith(".")] + rel_dir = os.path.relpath(dirpath, root) + for f in filenames: + yield (f if rel_dir == "." else f"{rel_dir}/{f}").replace(os.sep, "/") + except OSError: + return + + def _list_repo_files(root: str) -> list[str]: """File paths relative to ``root`` (tracked + untracked via ``git ls-files`` from the repo top; files outside ``root`` excluded so the picker stays Cmd-P scoped). Falls @@ -34,44 +70,10 @@ def _list_repo_files(root: str) -> list[str]: cached = _fuzzy_cache.get(root) if cached and now - cached[0] < _FUZZY_CACHE_TTL_S: return cached[1] - files: list[str] = [] - from hermes_cli._subprocess_compat import windows_hide_flags - run_kw = dict(capture_output=True, timeout=2.0, check=False, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) - try: - top_result = subprocess.run(["git", "-C", root, "rev-parse", "--show-toplevel"], **run_kw) - if top_result.returncode == 0: - top = top_result.stdout.decode("utf-8", "replace").strip() - list_result = subprocess.run( - ["git", "-C", top, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], **run_kw - ) - if list_result.returncode == 0: - for p in list_result.stdout.decode("utf-8", "replace").split("\0"): - if not p: - continue - rel = os.path.relpath(os.path.join(top, p), root).replace(os.sep, "/") - if rel.startswith("../"): # parents/siblings of cwd: keep Cmd-P workspace scope - continue - files.append(rel) - if len(files) >= _FUZZY_CACHE_MAX_FILES: - break - except (OSError, subprocess.TimeoutExpired): - pass + from itertools import islice + files = list(islice(_git_repo_files(root), _FUZZY_CACHE_MAX_FILES)) if not files: - # Fallback walk skips vendor/build dirs + dot-dirs; dotfiles survive (the ranker - # decides based on whether the query starts with `.`). - try: - for dirpath, dirnames, filenames in os.walk(root, followlinks=False): - dirnames[:] = [d for d in dirnames if d not in _FUZZY_FALLBACK_EXCLUDES and not d.startswith(".")] - rel_dir = os.path.relpath(dirpath, root) - for f in filenames: - rel = f if rel_dir == "." else f"{rel_dir}/{f}" - files.append(rel.replace(os.sep, "/")) - if len(files) >= _FUZZY_CACHE_MAX_FILES: - break - if len(files) >= _FUZZY_CACHE_MAX_FILES: - break - except OSError: - pass + files = list(islice(_walk_repo_files(root), _FUZZY_CACHE_MAX_FILES)) with _fuzzy_cache_lock: _fuzzy_cache[root] = (now, files) return files From 47c781c2a8db7980f170a01f88410ea7d9d8d879 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:47:01 -0700 Subject: [PATCH 7/7] refactor(tui_gateway): shared token estimator in compress feedback, iterator subsequence rank --- tui_gateway/methods_complete_helpers.py | 22 ++++++++-------------- tui_gateway/methods_slash.py | 14 ++++++-------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/tui_gateway/methods_complete_helpers.py b/tui_gateway/methods_complete_helpers.py index 6fd005dc38..3f36f8ff13 100644 --- a/tui_gateway/methods_complete_helpers.py +++ b/tui_gateway/methods_complete_helpers.py @@ -85,15 +85,13 @@ def _fuzzy_basename_rank(name: str, query: str) -> tuple[int, int] | None: · 3 substring · 4 subsequence (query chars appear in order).""" if not query: return (3, len(name)) - nl = name.lower() - ql = query.lower() + nl, ql = name.lower(), query.lower() if nl == ql: return (0, len(name)) if nl.startswith(ql): return (1, len(name)) - - # Split on -_. and camelCase (`appChrome` → ["app","Chrome"]); cheap approximation, - # falls through to substring/subsequence if it misses. + # Word boundaries: split on -_. and camelCase (`appChrome` → ["app","Chrome"]); cheap + # approximation, falls through to substring/subsequence if it misses. parts: list[str] = [] buf = "" for ch in name: @@ -105,17 +103,13 @@ def _fuzzy_basename_rank(name: str, query: str) -> tuple[int, int] | None: buf += ch if buf: parts.append(buf) - for p in parts: - if p.lower().startswith(ql): - return (2, len(name)) + if any(p.lower().startswith(ql) for p in parts): + return (2, len(name)) if ql in nl: return (3, len(name)) - i = 0 - for ch in nl: - if ch == ql[i]: - i += 1 - if i == len(ql): - return (4, len(name)) + it = iter(nl) + if all(any(c == q for c in it) for q in ql): + return (4, len(name)) return None diff --git a/tui_gateway/methods_slash.py b/tui_gateway/methods_slash.py index 41b0af0eab..a05a6976ce 100644 --- a/tui_gateway/methods_slash.py +++ b/tui_gateway/methods_slash.py @@ -265,9 +265,10 @@ def _compress_live_with_feedback(sid: str, session: dict, agent, arg: str, *, sn history_version = int(session.get("history_version", 0)) sys_prompt = getattr(agent, "_cached_system_prompt", "") or "" tools = getattr(agent, "tools", None) or None - before_tokens = ( - estimate_request_tokens_rough(before_messages, system_prompt=sys_prompt, tools=tools) if before_messages else 0 - ) + + def estimate(messages, prompt, tool_defs) -> int: + return estimate_request_tokens_rough(messages, system_prompt=prompt, tools=tool_defs) if messages else 0 + before_tokens = estimate(before_messages, sys_prompt, tools) try: if snapshot_kwargs: _compress_session_history( @@ -280,11 +281,8 @@ def _compress_live_with_feedback(sid: str, session: dict, agent, arg: str, *, sn _sync_session_key_after_compress(sid, session) with session["history_lock"]: after_messages = list(session.get("history", [])) - after_tokens = ( - estimate_request_tokens_rough( - after_messages, system_prompt=getattr(agent, "_cached_system_prompt", "") or sys_prompt, - tools=getattr(agent, "tools", None) or tools) - if after_messages else 0) + after_tokens = estimate( + after_messages, getattr(agent, "_cached_system_prompt", "") or sys_prompt, getattr(agent, "tools", None) or tools) _emit("session.info", sid, _session_info(agent, session)) fb = summarize_manual_compression( before_messages, after_messages, before_tokens, after_tokens,