Merge branch 'simp/r3-36-B' into simp/r3-36

This commit is contained in:
Teknium
2026-09-03 01:46:31 -07:00
4 changed files with 668 additions and 1000 deletions
+152 -191
View File
@@ -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:
+64 -109
View File
@@ -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
View File
@@ -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}"
File diff suppressed because it is too large Load Diff