feat(gateway): report per-skill usage and origin in commands.catalog

The catalog advertises every skill command but nothing about which ones
the user actually reaches for, so consumers can only sort them
alphabetically. Add a `skills` map keyed by slash command carrying the
activity count and origin (hub / bundled / local) already tracked in the
skills sidecars, read once per catalog build.

Additive: older clients ignore the field, and an unreadable sidecar
degrades to zero usage rather than failing the catalog.
This commit is contained in:
Brooklyn Nicholson 2026-07-28 19:14:46 -05:00
parent 3d0e543aa7
commit 75bf13e032
2 changed files with 111 additions and 0 deletions

View file

@ -7338,6 +7338,66 @@ def test_commands_catalog_surfaces_quick_commands(monkeypatch):
assert resp["result"]["canon"]["/notes"] == "/notes"
def test_commands_catalog_ranks_skill_commands_by_recorded_usage(monkeypatch):
"""Skill entries carry the usage + origin the `/` menu ranks on.
Without it the menu is alphabetical, so a bundled skill the user has never
opened outranks the one they invoke daily.
"""
monkeypatch.setattr(
server,
"_skill_usage_lookup",
lambda: (
lambda name: {"research": 60, "work": 172}.get(name, 0),
lambda name: "bundled" if name == "research-paper-writing" else "local",
),
)
monkeypatch.setattr(
"agent.skill_commands.scan_skill_commands",
lambda: {
"/research": {"name": "research", "description": "Look it up"},
"/research-paper-writing": {
"name": "research-paper-writing",
"description": "Write a paper",
},
"/work": {"name": "work", "description": "Fresh worktree"},
},
)
resp = server.handle_request(
{"id": "1", "method": "commands.catalog", "params": {}}
)
skills = resp["result"]["skills"]
assert skills["/work"] == {"usage": 172, "origin": "local"}
assert skills["/research"] == {"usage": 60, "origin": "local"}
assert skills["/research-paper-writing"] == {"usage": 0, "origin": "bundled"}
# Every advertised skill command is rankable — a missing entry silently
# sorts that skill to the bottom of the menu.
advertised = {name for name, _ in resp["result"]["pairs"]}
assert set(skills) <= advertised
assert resp["result"]["skill_count"] == len(skills)
def test_commands_catalog_survives_an_unreadable_usage_sidecar(monkeypatch):
"""A broken/absent .usage.json degrades to no ranking, never a broken menu."""
monkeypatch.setattr(
"tools.skill_usage.load_usage",
lambda: (_ for _ in ()).throw(OSError("sidecar is gone")),
)
resp = server.handle_request(
{"id": "1", "method": "commands.catalog", "params": {}}
)
assert "error" not in resp
assert all(
entry == {"usage": 0, "origin": "local"}
for entry in resp["result"]["skills"].values()
)
def test_commands_catalog_includes_tui_mouse_command():
resp = server.handle_request(
{"id": "1", "method": "commands.catalog", "params": {}}

View file

@ -15411,6 +15411,47 @@ _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset(
_WORKER_BLOCKED_COMMANDS: frozenset[str] = frozenset({"snapshot", "snap"})
def _skill_usage_lookup():
"""Build ``(usage, origin)`` callables for the skill-command catalog.
``usage(name)`` is the skill's observed activity count (use + view +
patch); ``origin(name)`` is ``"hub"``, ``"bundled"``, or ``"local"`` the
same classification ``/api/skills`` reports as ``provenance`` (where
"local" is spelled "agent"). Both read sidecar files that are cheap and
already parsed once per catalog build. Any failure degrades to zero usage
and ``"local"`` so a missing/corrupt sidecar can never break the catalog.
"""
try:
from tools.skill_usage import (
_read_bundled_manifest_names,
_read_hub_installed_names,
activity_count,
load_usage,
)
records = load_usage()
bundled = _read_bundled_manifest_names()
hub = _read_hub_installed_names()
except Exception as e:
logger.debug("skill usage lookup unavailable: %s", e)
return (lambda _name: 0), (lambda _name: "local")
def usage(name: str) -> int:
try:
return activity_count(records.get(name) or {})
except Exception:
return 0
def origin(name: str) -> str:
if name in hub:
return "hub"
if name in bundled:
return "bundled"
return "local"
return usage, origin
@method("commands.catalog")
def _(rid, params: dict) -> dict:
"""Registry-backed slash metadata for the TUI — categorized, no aliases."""
@ -15488,12 +15529,21 @@ def _(rid, params: dict) -> dict:
warning = f"quick_commands discovery unavailable: {e}"
skill_count = 0
skills: dict[str, dict] = {}
try:
from agent.skill_commands import scan_skill_commands
# Usage + origin per skill command. Surfaces here rather than in a
# second RPC because every consumer that renders the catalog also
# wants to rank it, and both reads are cheap sidecar files already
# loaded once per catalog build.
usage, origin_of = _skill_usage_lookup()
for k, info in sorted(scan_skill_commands().items()):
d = str(info.get("description", "Skill"))
all_pairs.append([k, d[:120] + ("" if len(d) > 120 else "")])
name = str(info.get("name") or k.lstrip("/"))
skills[k] = {"usage": usage(name), "origin": origin_of(name)}
skill_count += 1
except Exception as e:
warning = f"skill discovery unavailable: {e}"
@ -15509,6 +15559,7 @@ def _(rid, params: dict) -> dict:
"sub": sub,
"canon": canon,
"categories": categories,
"skills": skills,
"skill_count": skill_count,
"warning": warning,
},