Merge branch 'simp/r3-26-C' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 21:58:30 -07:00
3 changed files with 825 additions and 1469 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+166 -360
View File
@@ -1,4 +1,5 @@
"""Profile-scoped helpers: profile discovery fallback, profile dir/MCP-server writes, the profile/config scope context managers, skills-hub and tools/analytics catalog helpers.
"""Profile-scoped helpers: profile discovery fallback, profile dir/MCP-server writes,
the profile/config scope context managers, skills-hub and tools/analytics catalog helpers.
Split out of ``hermes_cli.web_server``; every externally used name is re-imported
there, so ``web_server.<name>`` keeps resolving (and monkeypatching) as before.
@@ -14,7 +15,7 @@ import threading
from contextlib import contextmanager
from fastapi import HTTPException
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
from hermes_cli.config import DEFAULT_CONFIG, get_process_hermes_home
from hermes_cli.web_models import MCPServerCreate
from hermes_cli.web_server_gateway import _ACTION_LOG_FILES
@@ -24,31 +25,49 @@ from hermes_cli.web_server_mcp import _normalize_mcp_server_create
_log = logging.getLogger("hermes_cli.web_server")
def _safe(callable_: Callable[[], Any], default: Any) -> Any:
try:
return callable_()
except Exception:
return default
def _is_current_profile(profile: Optional[str]) -> bool:
"""None/""/"current" all mean the dashboard's own profile."""
requested = (profile or "").strip()
return not requested or requested.lower() == "current"
@contextmanager
def _hermes_home_scope(path) -> Any:
"""Scope ``load_config``/``save_config`` (anything resolving ``get_hermes_home()`` at call
time) to ``path`` for the block via the context-local HERMES_HOME override."""
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
token = set_hermes_home_override(str(path))
try:
yield
finally:
reset_hermes_home_override(token)
def _is_other_profile(profile: Optional[str]) -> bool:
"""True when ``profile`` names a profile other than this process's own."""
from hermes_cli.web_server import _resolve_profile_dir
requested = (profile or "").strip()
if not requested or requested.lower() == "current":
if _is_current_profile(profile):
return False
try:
target = _resolve_profile_dir(requested)
target = _resolve_profile_dir(profile.strip())
except HTTPException:
return True
return target.resolve() != get_process_hermes_home().resolve()
def _approval_mode_of(config: Dict[str, Any]) -> str:
"""Normalize approvals.mode from an in-memory config document.
Both sides of the broadcast comparison use in-memory documents (the raw
on-disk dict and the about-to-be-saved dict): re-reading through the
config cache after a save can serve the pre-save document when the
replacement file collides on the (mtime_ns, size) cache key, which would
suppress the broadcast exactly when the mode changed. Absent block or
key normalizes to the same default the approval gate uses.
"""
"""Normalize approvals.mode from an in-memory config document. Both sides of the
broadcast comparison use in-memory documents: re-reading through the config cache after
a save can serve the pre-save document when the replacement file collides on the
(mtime_ns, size) cache key, suppressing the broadcast exactly when the mode changed."""
from tools.approval import _normalize_approval_mode
approvals = config.get("approvals")
default_mode = (DEFAULT_CONFIG.get("approvals") or {}).get("mode", "manual")
mode = approvals.get("mode", default_mode) if isinstance(approvals, dict) else default_mode
@@ -56,11 +75,8 @@ def _approval_mode_of(config: Dict[str, Any]) -> str:
def _broadcast_gateway_session_info() -> None:
"""Broadcast session.info on the in-process gateway when it's loaded.
``sys.modules`` guard, not an import: gateway never imported means no
live sessions in this process to notify.
"""
"""Broadcast session.info on the in-process gateway when it's loaded (``sys.modules``
guard, not an import: gateway never imported means no live sessions to notify)."""
server = sys.modules.get("tui_gateway.server")
if server is None:
return
@@ -71,12 +87,9 @@ def _broadcast_gateway_session_info() -> None:
def _parse_model_ids(resp: "Any") -> List[str]:
"""Extract model ids from an OpenAI-compatible ``/v1/models`` response.
Tolerant of the common shapes: ``{"data": [{"id": ...}]}`` (OpenAI / vLLM /
llama.cpp) and a bare ``{"data": ["id", ...]}``. Returns ``[]`` on any
parse/HTTP error so a slightly non-standard endpoint never hard-blocks.
"""
"""Model ids from an OpenAI-compatible ``/v1/models`` response: ``{"data": [{"id": ..}]}``
or a bare ``{"data": ["id", ..]}``. ``[]`` on any parse/HTTP error so a slightly
non-standard endpoint never hard-blocks."""
try:
if not resp.is_success:
return []
@@ -86,81 +99,53 @@ def _parse_model_ids(resp: "Any") -> List[str]:
data = payload.get("data") if isinstance(payload, dict) else payload
if not isinstance(data, list):
return []
ids: List[str] = []
for item in data:
if isinstance(item, dict):
mid = str(item.get("id") or "").strip()
else:
mid = str(item or "").strip()
if mid:
ids.append(mid)
return ids
ids = [str((item.get("id") if isinstance(item, dict) else item) or "").strip() for item in data]
return [mid for mid in ids if mid]
def _fallback_profile_entry(profiles_mod, name: str, home: Path, *, is_default: bool,
has_env: bool, gateway_running: Callable[[], bool]) -> Dict[str, Any]:
model, provider = _safe(lambda: profiles_mod._read_config_model(home), (None, None))
def meta(key, default):
return _safe(lambda: profiles_mod.read_profile_meta(home).get(key, default), default)
return {
"name": name, "path": str(home), "is_default": is_default,
"model": model, "provider": provider, "has_env": has_env,
"skill_count": _safe(lambda: profiles_mod._count_skills(home), 0),
"gateway_running": _safe(gateway_running, False),
"description": meta("description", ""), "description_auto": meta("description_auto", False),
"distribution_name": None, "distribution_version": None, "distribution_source": None,
"has_alias": False}
def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]:
def _safe(callable_, default):
try:
return callable_()
except Exception:
return default
profiles: List[Dict[str, Any]] = []
default_home = profiles_mod._get_default_hermes_home()
if default_home.is_dir():
model, provider = _safe(lambda: profiles_mod._read_config_model(default_home), (None, None))
profiles.append({
"name": "default",
"path": str(default_home),
"is_default": True,
"model": model,
"provider": provider,
"has_env": (default_home / ".env").exists(),
"skill_count": _safe(lambda: profiles_mod._count_skills(default_home), 0),
"gateway_running": _safe(lambda: profiles_mod._check_gateway_running(default_home), False),
"description": _safe(lambda: profiles_mod.read_profile_meta(default_home).get("description", ""), ""),
"description_auto": _safe(lambda: profiles_mod.read_profile_meta(default_home).get("description_auto", False), False),
"distribution_name": None,
"distribution_version": None,
"distribution_source": None,
"has_alias": False,
})
profiles.append(_fallback_profile_entry(
profiles_mod, "default", default_home, is_default=True,
has_env=(default_home / ".env").exists(),
gateway_running=lambda: profiles_mod._check_gateway_running(default_home)))
profiles_root = profiles_mod._get_profiles_root()
if profiles_root.is_dir():
# Use os.scandir (context-managed) instead of Path.iterdir to avoid
# leaking directory fds when an exception interrupts iteration — the
# sidebar polls every few seconds so an fd leak exhausts RLIMIT_NOFILE
# within days (#81547).
# os.scandir (context-managed) rather than Path.iterdir: an exception mid-iteration
# must not leak the directory fd — the sidebar polls every few seconds, so a leak
# exhausts RLIMIT_NOFILE within days.
with os.scandir(profiles_root) as scan:
entries = sorted(scan, key=lambda e: e.name)
for entry in entries:
entry_path = Path(entry.path)
home = Path(entry.path)
if not entry.is_dir() or not profiles_mod._PROFILE_ID_RE.match(entry.name):
continue
model, provider = _safe(lambda entry=entry_path: profiles_mod._read_config_model(entry), (None, None))
profiles.append({
"name": entry.name,
"path": str(entry_path),
"is_default": False,
"model": model,
"provider": provider,
"has_env": _safe(lambda entry=entry_path: (entry / ".env").exists(), False),
"skill_count": _safe(lambda entry=entry_path: profiles_mod._count_skills(entry), 0),
"gateway_running": _safe(
lambda entry=entry_path, name=entry.name: (
profiles_mod._check_gateway_running(entry)
or profiles_mod._served_by_running_multiplexer(name)
),
False,
),
"description": _safe(lambda entry=entry_path: profiles_mod.read_profile_meta(entry).get("description", ""), ""),
"description_auto": _safe(lambda entry=entry_path: profiles_mod.read_profile_meta(entry).get("description_auto", False), False),
"distribution_name": None,
"distribution_version": None,
"distribution_source": None,
"has_alias": False,
})
profiles.append(_fallback_profile_entry(
profiles_mod, entry.name, home, is_default=False,
has_env=_safe(lambda: (home / ".env").exists(), False),
gateway_running=lambda home=home, name=entry.name: (
profiles_mod._check_gateway_running(home)
or profiles_mod._served_by_running_multiplexer(name))))
return profiles
@@ -177,24 +162,15 @@ def _resolve_profile_dir(name: str) -> Path:
def _write_profile_mcp_servers(profile_dir: Path, servers: List["MCPServerCreate"]) -> int:
"""Write MCP server entries into a specific profile's config.yaml.
"""Write MCP server entries into ``profile_dir``'s config.yaml (HERMES_HOME-scoped).
Scopes ``load_config``/``save_config`` to ``profile_dir`` via the
context-local HERMES_HOME override (same mechanism as
``_write_profile_model``) so the entries land in the target profile's
config rather than the dashboard process's active profile.
Mirrors the per-server shape the ``POST /api/mcp/servers`` endpoint builds,
but batched so the whole profile-create write is a single config save.
Returns the number of servers written.
Mirrors the per-server shape ``POST /api/mcp/servers`` builds, batched so the whole
profile-create write is one config save. Returns the number of servers written.
"""
from hermes_cli.web_server import load_config, save_config
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
from hermes_cli.mcp_config import _save_bearer_auth_token
written = 0
token = set_hermes_home_override(str(profile_dir))
try:
with _hermes_home_scope(profile_dir):
cfg = load_config()
mcp = cfg.setdefault("mcp_servers", {})
for server in servers:
@@ -202,11 +178,7 @@ def _write_profile_mcp_servers(profile_dir: Path, servers: List["MCPServerCreate
name, entry, bearer_token = _normalize_mcp_server_create(server)
except ValueError as exc:
display_name = (server.name or "").strip() or "<unnamed>"
_log.warning(
"Profile-create: skipping MCP server '%s': %s",
display_name,
exc,
)
_log.warning("Profile-create: skipping MCP server '%s': %s", display_name, exc)
continue
if bearer_token is not None:
entry["headers"] = _save_bearer_auth_token(name, bearer_token)
@@ -215,26 +187,15 @@ def _write_profile_mcp_servers(profile_dir: Path, servers: List["MCPServerCreate
if written:
save_config(cfg)
elif not mcp:
# We created an empty mcp_servers dict but wrote nothing — don't
# leave a stray empty key in the new profile's config.
# Don't leave the stray empty key we just created in the new profile's config.
cfg.pop("mcp_servers", None)
save_config(cfg)
finally:
reset_hermes_home_override(token)
return written
# ---------------------------------------------------------------------------
# Skills & Tools endpoints
#
# Every read/write below accepts an optional ``profile`` query param so the
# dashboard can manage ANY profile's skills/toolsets, not just the profile
# the dashboard process happens to be running under. Without this, "Set as
# active" on the Profiles page (which only flips the sticky ``active_profile``
# file for FUTURE CLI/gateway invocations) misled users into thinking skill
# toggles would land in the activated profile — they silently wrote into the
# dashboard's own config instead. See _profile_scope() for the mechanism.
# ---------------------------------------------------------------------------
# Skills & Tools endpoints accept an optional ``profile`` query param so the dashboard can
# manage ANY profile's skills/toolsets ("Set as active" only flips the sticky active_profile
# file for FUTURE invocations, which misled users into thinking toggles landed there).
_SKILLS_PROFILE_LOCK = threading.RLock()
@@ -244,146 +205,60 @@ _SKILLS_PROFILE_LOCK = threading.RLock()
def _profile_scope(profile: Optional[str]):
"""Scope config + skill-directory resolution to ``profile`` for one request.
Two seams must be redirected for skills/toolsets endpoints:
1. ``load_config``/``save_config`` resolve ``get_hermes_home()`` at call
time — the context-local override from ``set_hermes_home_override``
reaches them (same pattern as ``_write_profile_model``).
2. ``tools.skills_tool`` and ``tools.skill_manager_tool`` bind
``SKILLS_DIR`` at import time, so the override CANNOT reach them.
Like ``_call_cron_for_profile`` does for cron's module globals,
temporarily retarget both under a lock and restore them
immediately after.
``tools.skills_sync`` (reset/diff/list-modified/opt-in/opt-out/
repair-official) needs NO retargeting: since #65828 its directory
lookups resolve at call time through the same contextvar override
set in step 1.
``profile`` of None/""/"current" means "the dashboard's own profile" —
config resolution is untouched, but the skill-module globals are still
retargeted to the *current* ``get_hermes_home()`` so writes land in the
live home even when the import-time binding is stale (e.g. the process
imported the modules before a HERMES_HOME override, or under test
isolation).
Two seams: (1) ``load_config``/``save_config`` resolve ``get_hermes_home()`` at call
time, so the contextvar override reaches them; (2) ``tools.skills_tool`` /
``tools.skill_manager_tool`` bind ``SKILLS_DIR`` at import time, so both are retargeted
under a lock and restored after. For the dashboard's own profile config resolution is
untouched, but the skill-module globals are still retargeted to the *current*
``get_hermes_home()`` so writes land in the live home even when the import-time binding
is stale (test isolation, late HERMES_HOME override). Yields the profile dir for a named
profile, None for the current one.
"""
from hermes_cli.web_server import _resolve_profile_dir
requested = (profile or "").strip()
from hermes_constants import (
get_hermes_home,
set_hermes_home_override,
reset_hermes_home_override,
)
from hermes_constants import get_hermes_home
from tools import skills_tool as _skills_tool
from tools import skill_manager_tool as _skill_mgr
token = None
if not requested or requested.lower() == "current":
profile_dir = get_hermes_home()
else:
profile_dir = _resolve_profile_dir(requested)
token = set_hermes_home_override(str(profile_dir))
with _SKILLS_PROFILE_LOCK:
old_home = _skills_tool.HERMES_HOME
old_skills_dir = _skills_tool.SKILLS_DIR
old_mgr_home = _skill_mgr.HERMES_HOME
old_mgr_skills_dir = _skill_mgr.SKILLS_DIR
_skills_tool.HERMES_HOME = profile_dir
_skills_tool.SKILLS_DIR = profile_dir / "skills"
_skill_mgr.HERMES_HOME = profile_dir
_skill_mgr.SKILLS_DIR = profile_dir / "skills"
try:
yield profile_dir if token is not None else None
finally:
_skills_tool.HERMES_HOME = old_home
_skills_tool.SKILLS_DIR = old_skills_dir
_skill_mgr.HERMES_HOME = old_mgr_home
_skill_mgr.SKILLS_DIR = old_mgr_skills_dir
if token is not None:
reset_hermes_home_override(token)
with _config_profile_scope(profile) as scoped:
profile_dir = get_hermes_home() if scoped is None else scoped
modules = (_skills_tool, _skill_mgr)
with _SKILLS_PROFILE_LOCK:
saved = [(m.HERMES_HOME, m.SKILLS_DIR) for m in modules]
for m in modules:
m.HERMES_HOME, m.SKILLS_DIR = profile_dir, profile_dir / "skills"
try:
yield scoped
finally:
for m, (home, skills_dir) in zip(modules, saved):
m.HERMES_HOME, m.SKILLS_DIR = home, skills_dir
@contextmanager
def _config_profile_scope(profile: Optional[str]):
"""Await-safe, config-only profile scope for handlers that ``await``.
Unlike ``_profile_scope`` this touches ONLY the context-local
``set_hermes_home_override`` contextvar — it does NOT swap the
process-global ``skills_tool``/``skill_manager`` module attributes.
Those globals are shared across all event-loop tasks, so holding them
across an ``await`` lets a concurrent skills request restore THIS
request's profile dir on its ``finally`` (cross-contamination). The
contextvar override is task-local and survives an ``await`` cleanly,
which is all endpoints that resolve ``get_hermes_home()`` at call time
(config, env, gateway status) actually need.
None/""/"current" means the dashboard's own profile — no override.
"""
"""Await-safe, config-only profile scope: touches ONLY the task-local HERMES_HOME
contextvar, never the process-global skills-module attributes ``_profile_scope`` swaps
(holding those across an ``await`` lets a concurrent request restore THIS request's dir
on its ``finally``). None/""/"current" = no override."""
from hermes_cli.web_server import _resolve_profile_dir
requested = (profile or "").strip()
if not requested or requested.lower() == "current":
if _is_current_profile(profile):
yield None
return
from hermes_constants import (
set_hermes_home_override,
reset_hermes_home_override,
)
profile_dir = _resolve_profile_dir(requested)
token = set_hermes_home_override(str(profile_dir))
try:
profile_dir = _resolve_profile_dir(profile.strip())
with _hermes_home_scope(profile_dir):
yield profile_dir
finally:
reset_hermes_home_override(token)
# ---------------------------------------------------------------------------
# Terminal execution backend picker — the GUI counterpart of terminal.backend
# in config.yaml. Each row carries a fast, defensive health probe (Docker
# daemon reachable, SSH host configured, Modal/Daytona credentials present) so
# the Capabilities panel can render Ready / Needs setup guidance instead of a
# bare enum (issues #57738 / #63783). Probes must never raise — a probe
# failure renders as a status, not a 500.
# ---------------------------------------------------------------------------
# Terminal backend picker rows — GUI counterpart of terminal.backend. Keep in sync with
# tools/terminal_tool.py::_create_environment and the terminal.backend enum.
# Table-driven backend metadata — kept in sync with the dispatch ladder in
# tools/terminal_tool.py::_create_environment and the terminal.backend enum
# surfaced in the desktop raw-config settings.
_TERMINAL_BACKENDS: List[Dict[str, str]] = [
{
"name": "local",
"label": "Local",
"description": "Run commands directly on this machine. No isolation.",
},
{
"name": "docker",
"label": "Docker",
"description": "Run commands in an isolated Docker container with a persistent workspace.",
},
{
"name": "singularity",
"label": "Singularity / Apptainer",
"description": "Run commands in a Singularity/Apptainer container (HPC-friendly, rootless).",
},
{
"name": "modal",
"label": "Modal",
"description": "Run commands in a Modal cloud sandbox.",
},
{
"name": "daytona",
"label": "Daytona",
"description": "Run commands in a Daytona cloud sandbox.",
},
{
"name": "ssh",
"label": "SSH",
"description": "Run commands on a remote host over SSH.",
},
]
dict(zip(("name", "label", "description"), row)) for row in (
("local", "Local", "Run commands directly on this machine. No isolation."),
("docker", "Docker",
"Run commands in an isolated Docker container with a persistent workspace."),
("singularity", "Singularity / Apptainer",
"Run commands in a Singularity/Apptainer container (HPC-friendly, rootless)."),
("modal", "Modal", "Run commands in a Modal cloud sandbox."),
("daytona", "Daytona", "Run commands in a Daytona cloud sandbox."),
("ssh", "SSH", "Run commands on a remote host over SSH."))]
def _plugin_terminal_backend_rows() -> List[Dict[str, str]]:
@@ -391,20 +266,15 @@ def _plugin_terminal_backend_rows() -> List[Dict[str, str]]:
rows: List[Dict[str, str]] = []
try:
from hermes_cli.plugins import discover_plugins
discover_plugins() # idempotent — plugin state may not be loaded yet
except Exception:
pass
try:
from agent.terminal_env_registry import list_providers
for provider in list_providers():
try:
rows.append({
"name": provider.name.strip().lower(),
"label": provider.display_name,
"description": provider.description,
})
rows.append({"name": provider.name.strip().lower(), "label": provider.display_name,
"description": provider.description})
except Exception:
continue
except Exception:
@@ -412,18 +282,18 @@ def _plugin_terminal_backend_rows() -> List[Dict[str, str]]:
return rows
# ---------------------------------------------------------------------------
# Token / cost analytics endpoint
# ---------------------------------------------------------------------------
# Token / cost analytics helpers.
_AUX_COUNTERS = ("input_tokens", "output_tokens", "estimated_cost", "api_calls")
def _token_volume(row: Dict[str, Any]) -> Any:
return (row.get("input_tokens") or 0) + (row.get("output_tokens") or 0)
def _aux_usage_rows(db, cutoff: float) -> List[Dict[str, Any]]:
"""Per-(model, task) auxiliary usage within the window (issue #23270).
Reads the task-dimension rows (task != '') that record_auxiliary_usage
writes into session_model_usage. Returns [] when the table predates the
task column (older DB opened read-only by newer code).
"""
"""Per-(model, task) auxiliary usage within the window: the task-dimension rows
(task != '') record_auxiliary_usage writes into session_model_usage. [] when the
table predates the task column (older DB opened read-only by newer code)."""
try:
cur = db._conn.execute("""
SELECT u.model,
@@ -445,57 +315,26 @@ def _aux_usage_rows(db, cutoff: float) -> List[Dict[str, Any]]:
""", (cutoff,))
return [dict(r) for r in cur.fetchall()]
except Exception:
# Table predates the task column (older DB opened by newer code) —
# aux breakdown is simply unavailable.
return []
def _merge_aux_into_by_model(
by_model: List[Dict[str, Any]], aux_rows: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Fold aux usage rows into the sessions-derived per-model list.
Aux usage lives only in session_model_usage (never in the sessions
counters), so adding it here cannot double-count. Models that ONLY
appear via aux calls (e.g. a dedicated vision model) get their own
entry — previously they were entirely invisible.
"""
by_model: List[Dict[str, Any]], aux_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Fold aux usage rows into the sessions-derived per-model list. Aux usage lives only in
session_model_usage (never in the sessions counters), so this cannot double-count;
models that ONLY appear via aux calls (e.g. a vision model) get their own entry."""
if not aux_rows:
return by_model
merged: Dict[str, Dict[str, Any]] = {}
for row in by_model:
merged[row.get("model") or "unknown"] = row
merged: Dict[str, Dict[str, Any]] = {row.get("model") or "unknown": row for row in by_model}
for aux in aux_rows:
model = aux.get("model") or "unknown"
target = merged.get(model)
if target is None:
target = {
"model": model,
"input_tokens": 0,
"output_tokens": 0,
"estimated_cost": 0,
"sessions": 0,
"api_calls": 0,
}
merged[model] = target
target["input_tokens"] = (target.get("input_tokens") or 0) + (aux.get("input_tokens") or 0)
target["output_tokens"] = (target.get("output_tokens") or 0) + (aux.get("output_tokens") or 0)
target["estimated_cost"] = (target.get("estimated_cost") or 0) + (aux.get("estimated_cost") or 0)
target["api_calls"] = (target.get("api_calls") or 0) + (aux.get("api_calls") or 0)
tasks = target.setdefault("aux_tasks", [])
tasks.append({
"task": aux.get("task") or "",
"input_tokens": aux.get("input_tokens") or 0,
"output_tokens": aux.get("output_tokens") or 0,
"estimated_cost": aux.get("estimated_cost") or 0,
"api_calls": aux.get("api_calls") or 0,
})
result = list(merged.values())
result.sort(
key=lambda r: (r.get("input_tokens") or 0) + (r.get("output_tokens") or 0),
reverse=True,
)
return result
target = merged.setdefault(model, {"model": model, "input_tokens": 0, "output_tokens": 0,
"estimated_cost": 0, "sessions": 0, "api_calls": 0})
for key in _AUX_COUNTERS:
target[key] = (target.get(key) or 0) + (aux.get(key) or 0)
target.setdefault("aux_tasks", []).append(
{"task": aux.get("task") or "", **{key: aux.get(key) or 0 for key in _AUX_COUNTERS}})
return sorted(merged.values(), key=_token_volume, reverse=True)
def _aux_task_summary(aux_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@@ -503,38 +342,20 @@ def _aux_task_summary(aux_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
by_task: Dict[str, Dict[str, Any]] = {}
for aux in aux_rows:
task = aux.get("task") or ""
d = by_task.setdefault(task, {
"task": task,
"input_tokens": 0,
"output_tokens": 0,
"estimated_cost": 0,
"api_calls": 0,
"models": [],
})
d["input_tokens"] += aux.get("input_tokens") or 0
d["output_tokens"] += aux.get("output_tokens") or 0
d["estimated_cost"] += aux.get("estimated_cost") or 0
d["api_calls"] += aux.get("api_calls") or 0
d = by_task.setdefault(task, {"task": task, "input_tokens": 0, "output_tokens": 0,
"estimated_cost": 0, "api_calls": 0, "models": []})
for key in _AUX_COUNTERS:
d[key] += aux.get(key) or 0
model = aux.get("model") or "unknown"
if model not in d["models"]:
d["models"].append(model)
result = list(by_task.values())
result.sort(
key=lambda r: (r.get("input_tokens") or 0) + (r.get("output_tokens") or 0),
reverse=True,
)
return result
return sorted(by_task.values(), key=_token_volume, reverse=True)
def _profile_cli_args(profile: Optional[str]) -> List[str]:
"""Return ``["-p", <name>]`` for a validated non-default profile.
Hub install/uninstall/update run in a fresh ``hermes`` subprocess, and
``_apply_profile_override()`` reads ``-p`` from argv in the child — the
only mechanism that reaches import-time-bound globals like
``skills_hub.SKILLS_DIR``. Empty/"current" means the dashboard's own
profile (no args, legacy behavior).
"""
"""``["-p", <name>]`` for a validated non-default profile, else ``[]``. Hub actions run in
a fresh ``hermes`` subprocess whose ``_apply_profile_override()`` reads ``-p`` from argv —
the only mechanism that reaches import-time-bound globals like ``skills_hub.SKILLS_DIR``."""
requested = (profile or "").strip()
if not requested or requested.lower() in {"current", "default"}:
return []
@@ -544,13 +365,9 @@ def _profile_cli_args(profile: Optional[str]) -> List[str]:
def _hub_action_name(verb: str, key: str) -> str:
"""Unique per-skill hub action name (+ registered log file).
``_spawn_hermes_action`` tracks one process/log per name, so a shared
"skills-install"/"skills-uninstall" would make concurrent row-level actions
overwrite each other's status/log while the UI polls per identifier. Slug
(readable) + hash (collision-proof) keys each action to its own row.
"""
"""Unique per-skill hub action name (+ registered log file): ``_spawn_hermes_action``
tracks one process/log per name, so a shared "skills-install" would make concurrent
row-level actions overwrite each other. Slug (readable) + hash (collision-proof)."""
slug = re.sub(r"[^a-z0-9]+", "-", key.lower()).strip("-")[:48] or "skill"
digest = hashlib.sha1(key.encode()).hexdigest()[:8]
name = f"skills-{verb}-{slug}-{digest}"
@@ -559,31 +376,20 @@ def _hub_action_name(verb: str, key: str) -> str:
def _installed_hub_identifiers(profile: Optional[str] = None) -> dict:
"""Map identifier -> installed lock entry for hub-installed skills.
Lets the UI mark search results that are already installed. Scoped to
``profile``'s skills/.hub/lock.json when provided (HubLockFile takes an
explicit path, sidestepping the import-time LOCK_FILE binding).
Best-effort: returns an empty dict if the lock file can't be read.
"""
"""identifier -> installed lock entry for hub-installed skills (UI marks installed search
results). Scoped to ``profile``'s skills/.hub/lock.json when given — HubLockFile takes an
explicit path, sidestepping the import-time LOCK_FILE binding. {} when unreadable."""
try:
from tools.skills_hub import HubLockFile
requested = (profile or "").strip()
if requested and requested.lower() != "current":
profile_dir = _resolve_profile_dir(requested)
lock = HubLockFile(profile_dir / "skills" / ".hub" / "lock.json")
else:
if _is_current_profile(profile):
lock = HubLockFile()
out = {}
for entry in lock.list_installed():
ident = entry.get("identifier")
if ident:
out[ident] = {
"name": entry.get("name"),
"trust_level": entry.get("trust_level"),
"scan_verdict": entry.get("scan_verdict"),
}
return out
else:
profile_dir = _resolve_profile_dir(profile.strip())
lock = HubLockFile(profile_dir / "skills" / ".hub" / "lock.json")
return {
entry["identifier"]: {"name": entry.get("name"),
"trust_level": entry.get("trust_level"),
"scan_verdict": entry.get("scan_verdict")}
for entry in lock.list_installed() if entry.get("identifier")}
except Exception:
return {}