Merge branch 'simp/r3-36-B' into simp/r3-36
This commit is contained in:
+152
-191
@@ -13,19 +13,12 @@ _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]"),
|
||||
]
|
||||
("/mouse", "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]")]
|
||||
|
||||
|
||||
def _item(text: str, meta: str, display: str | None = None) -> dict:
|
||||
@@ -38,17 +31,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 +49,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 +70,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 +81,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 +113,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 +129,65 @@ 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]]
|
||||
|
||||
|
||||
def _at_root_items() -> list[dict]:
|
||||
"""Completions for a bare ``@``: directive hints, agent profiles, plugin ``@<prefix>:`` 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")
|
||||
@@ -156,33 +195,19 @@ 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 `@<prefix>:<query>` 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, ""
|
||||
@@ -190,73 +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})
|
||||
|
||||
|
||||
@@ -265,15 +240,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 +263,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 +274,111 @@ 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:
|
||||
|
||||
@@ -19,12 +19,47 @@ _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]]] = {}
|
||||
|
||||
|
||||
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
|
||||
@@ -35,51 +70,12 @@ 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
|
||||
|
||||
|
||||
@@ -89,18 +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:
|
||||
@@ -112,20 +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
|
||||
|
||||
|
||||
@@ -136,13 +120,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))
|
||||
@@ -150,14 +131,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")
|
||||
|
||||
@@ -169,55 +142,40 @@ 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 []
|
||||
|
||||
|
||||
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 +183,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
|
||||
)
|
||||
|
||||
+77
-137
@@ -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
|
||||
@@ -43,15 +40,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)
|
||||
@@ -60,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:
|
||||
@@ -73,29 +67,18 @@ 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')}",
|
||||
]
|
||||
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]:
|
||||
@@ -104,16 +87,13 @@ 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
|
||||
session["session_key"], include_ancestors=True, include_row_ids=True)
|
||||
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)
|
||||
@@ -131,7 +111,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:
|
||||
@@ -140,14 +120,14 @@ 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}"
|
||||
|
||||
|
||||
def _format_live_context_output(session: dict) -> 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:
|
||||
@@ -158,14 +138,10 @@ def _format_live_context_output(session: dict) -> 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)}"
|
||||
)
|
||||
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}")
|
||||
@@ -183,7 +159,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:
|
||||
@@ -194,10 +170,9 @@ 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:", ""]
|
||||
for category, commands in COMMANDS_BY_CATEGORY.items():
|
||||
lines.append(f"{category}:")
|
||||
@@ -216,30 +191,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 <name> to rename this session."),
|
||||
"effort": (None, lambda sid, s, a: "Use /reasoning <effort> 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 <name> to rename this session."),
|
||||
"effort": (None, "Use /reasoning <effort> to change reasoning effort.")}
|
||||
|
||||
|
||||
def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg: str) -> Optional[str]:
|
||||
@@ -248,10 +226,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:
|
||||
@@ -259,7 +234,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 ────────────────────────────────────────────
|
||||
@@ -270,67 +245,54 @@ 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))
|
||||
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
|
||||
)
|
||||
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)
|
||||
|
||||
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(
|
||||
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_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,
|
||||
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:
|
||||
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:
|
||||
@@ -345,7 +307,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 +321,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 +344,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 +356,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 +373,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 +400,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 +412,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}"
|
||||
|
||||
|
||||
+375
-563
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user