feat(plugin-catalog): default shelf is Desktop, catch-all is General; categorise today's six entries

Teknium's call: most community submissions are Desktop panes, so an entry
without a category lands on the Desktop shelf; "other" becomes "general" for
plugins that genuinely span areas. Shelf order puts Desktop first. The six
entries merged today (pets-all, newswire, auto-titler, live-voice,
metamask-wallet, web-octen) get explicit categories.
This commit is contained in:
teknium1
2026-09-14 20:48:33 -07:00
committed by Teknium
parent 55dbd7f6e1
commit 2de17e5d40
14 changed files with 29 additions and 22 deletions
+5 -4
View File
@@ -27,8 +27,9 @@ import yaml
logger = logging.getLogger(__name__)
CATALOG_TIERS = ("official", "community")
# Browse taxonomy for the catalog page / picker. Entries without one land in "other".
CATALOG_CATEGORIES = ("memory", "desktop", "platform", "web", "tools", "voice", "automation", "models", "other")
# Browse taxonomy for the catalog page / picker. Entries without one land on the Desktop shelf
# (the common case for community submissions); "general" is for plugins that fit no shelf.
CATALOG_CATEGORIES = ("desktop", "memory", "platform", "web", "tools", "voice", "automation", "models", "general")
LIVE_CATALOG_URL = "https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json"
LIVE_CATALOG_TTL_SECONDS = 6 * 60 * 60
_REQUEST_TIMEOUT = 5.0
@@ -62,7 +63,7 @@ class PluginCatalogEntry:
description: str
maintainer: str
tier: str = "community"
category: str = "other"
category: str = "desktop"
requires_hermes: str = ""
subdir: str = ""
docs_url: str = ""
@@ -108,7 +109,7 @@ def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]:
repo = str(data.get("repo") or "")
sha = str(data.get("sha") or "").strip().lower()
tier = str(data.get("tier") or "community")
category = str(data.get("category") or "other")
category = str(data.get("category") or "desktop")
problem = (
f"invalid name {name!r} (must match [a-z0-9_-]{{1,64}})" if not _NAME_RE.match(name)
else f"repo must be an https:// URL (got {repo!r})" if not repo.startswith("https://")
+2 -2
View File
@@ -44,8 +44,8 @@ subdir: "" # optional path within the repo
description: One-line description.
maintainer: OwnerName
tier: official # official | community (default community)
category: memory # memory | desktop | platform | web | tools | voice | automation | models | other
# (default other) — the shelf the entry sits on at /docs/plugins
category: memory # desktop | memory | platform | web | tools | voice | automation | models | general
# (default desktop) — the shelf the entry sits on at /docs/plugins
requires_hermes: ">=0.19" # optional
docs_url: "" # optional
platforms: [] # optional, e.g. [linux, macos]; empty = all
+1
View File
@@ -4,6 +4,7 @@ sha: 307993c36e261af2769e486befc6841e96f93423
description: Provenance-aware automatic session title maintenance that never overrides user titles.
maintainer: Vocllum
tier: community
category: automation
docs_url: https://github.com/Vocllum/hermes-auto-titler
platforms: []
capabilities:
+1
View File
@@ -4,6 +4,7 @@ sha: 5f0022b97105da26783854aca9dac855c9ca865f
description: 'Full-duplex Live Voice for Hermes Desktop — GPT-Live-1 on the ChatGPT/Codex subscription via the local Codex app-server, with live transcript, voice tool calls and task delegation to the user chat.'
maintainer: Synero
tier: community
category: voice
docs_url: https://github.com/Synero/hermes-live-voice
platforms: []
capabilities:
+1
View File
@@ -6,6 +6,7 @@ description: 'Breaking-news ticker for Hermes Desktop: a scrolling RSS/Atom/JSON
in-app reading. Zero API keys, zero model tokens.'
maintainer: tony-simons-aiowa
tier: community
category: desktop
requires_hermes: '>=0.19'
docs_url: https://github.com/tony-simons-aiowa/hermes-newswire
platforms: []
+1
View File
@@ -5,6 +5,7 @@ description: Browse every pet installed for this Hermes profile in its own deskt
(thumbnails, search, click-to-adopt), plus a read-only list_pets tool for the agent.
maintainer: poponline63
tier: community
category: desktop
requires_hermes: ">=0.19"
docs_url: https://github.com/poponline63/hermes-pets-all
platforms: []
+1
View File
@@ -5,6 +5,7 @@ subdir: ""
description: MetaMask Agent Wallet for Hermes — chat-only setup, reads, and approval-gated transfers/swaps/signatures, a gated Guard Mode policy editor, and a background 2FA watcher. Keys stay in MetaMask.
maintainer: akugone
tier: community
category: tools
requires_hermes: ">=0.21"
docs_url: https://github.com/akugone/hermes-metamask-wallet
platforms: [macos, linux]
+1
View File
@@ -4,6 +4,7 @@ sha: 3af8a3fe200d93934c823485f66c3375a4508474
description: Octen web search with query-focused highlights and page extraction.
maintainer: Octen-Team
tier: community
category: web
docs_url: https://github.com/Octen-Team/hermes-octen-plugin#readme
capabilities:
provides_tools: []
+2 -2
View File
@@ -42,7 +42,7 @@ except ImportError: # pragma: no cover - dependency guidance only
NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
TIERS = ("official", "community")
CATEGORIES = ("memory", "desktop", "platform", "web", "tools", "voice", "automation", "models", "other")
CATEGORIES = ("desktop", "memory", "platform", "web", "tools", "voice", "automation", "models", "general")
PLATFORMS = ("linux", "macos", "windows")
CAPABILITY_KEYS = (
"provides_tools",
@@ -127,7 +127,7 @@ def validate_entry(data: object) -> tuple[list[str], list[str]]:
if tier not in TIERS:
errors.append(f"tier {tier!r} must be one of {list(TIERS)}")
category = data.get("category", "other")
category = data.get("category", "desktop")
if category not in CATEGORIES:
errors.append(f"category {category!r} must be one of {list(CATEGORIES)}")
+2 -2
View File
@@ -31,14 +31,14 @@ def test_shipped_catalog_entries_are_all_valid_and_pinned():
def test_category_defaults_to_other_and_unknown_category_is_rejected(tmp_path):
"""``category`` is the browse shelf: absent → ``other``; a value outside CATALOG_CATEGORIES is an
"""``category`` is the browse shelf: absent → ``desktop``; a value outside CATALOG_CATEGORIES is an
invalid entry (skipped with a warning) so a typo cannot create a phantom shelf on the site."""
(tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("no-cat")))
(tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("mem", category="memory")))
(tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("typo", category="memmory")))
entries = {e.name: e for e in pc.load_catalog(tmp_path)}
assert set(entries) == {"no-cat", "mem"}
assert entries["no-cat"].category == "other" and entries["mem"].category == "memory"
assert entries["no-cat"].category == "desktop" and entries["mem"].category == "memory"
assert entries["mem"].to_dict()["category"] == "memory"
+3 -3
View File
@@ -141,7 +141,7 @@ def test_main_writes_catalog_and_meta(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "alpha", tier="official", category="memory")
_write_entry(catalog, "beta") # no category → "other" shelf
_write_entry(catalog, "beta") # no category → default "desktop" shelf
(catalog / "removed.yaml").write_text(
"removed:\n - name: gone\n", encoding="utf-8"
)
@@ -155,8 +155,8 @@ def test_main_writes_catalog_and_meta(mod, tmp_path):
assert [p["name"] for p in plugins] == ["alpha", "beta"]
assert meta["total"] == 2
assert meta["byTier"] == {"official": 1, "community": 1}
assert {p["name"]: p["category"] for p in plugins} == {"alpha": "memory", "beta": "other"}
assert meta["byCategory"] == {"memory": 1, "other": 1}
assert {p["name"]: p["category"] for p in plugins} == {"alpha": "memory", "beta": "desktop"}
assert meta["byCategory"] == {"desktop": 1, "memory": 1}
assert meta["removedCount"] == 1
assert meta["generatedAt"]
# The live-refresh document consumed by installed clients: loader-schema entries + the kill list.
@@ -36,7 +36,7 @@ directory of the hermes-agent repository, declaring:
| `repo` | The plugin's public git repository |
| `sha` | The **exact 40-hex commit** that was reviewed — installs check out this pin, not a branch tip |
| `tier` | `official` (maintained by NousResearch) or `community` |
| `category` | Browse shelf: `memory`, `desktop`, `platform`, `web`, `tools`, `voice`, `automation`, `models` or `other` (default) |
| `category` | Browse shelf: `desktop` (default), `memory`, `platform`, `web`, `tools`, `voice`, `automation`, `models` or `general` |
| `maintainer` | Who owns the plugin |
| `capabilities` | Declared tools, hooks, middleware, and required env vars |
| `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional) |
+4 -4
View File
@@ -40,7 +40,7 @@ DEFAULT_CATALOG_DIR = REPO_ROOT / "plugin-catalog"
DEFAULT_OUTPUT_DIR = REPO_ROOT / "website" / "static" / "api"
CATALOG_TIERS = ("official", "community")
CATALOG_CATEGORIES = ("memory", "desktop", "platform", "web", "tools", "voice", "automation", "models", "other")
CATALOG_CATEGORIES = ("desktop", "memory", "platform", "web", "tools", "voice", "automation", "models", "general")
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
@@ -107,10 +107,10 @@ def load_catalog_entries(catalog_dir: Path) -> list[dict]:
if tier not in CATALOG_TIERS:
_log(f"{path.name} ({name}): unknown tier {tier!r}, treating as community")
tier = "community"
category = str(raw.get("category") or "other").strip().lower()
category = str(raw.get("category") or "desktop").strip().lower()
if category not in CATALOG_CATEGORIES:
_log(f"{path.name} ({name}): unknown category {category!r}, treating as other")
category = "other"
_log(f"{path.name} ({name}): unknown category {category!r}, treating as general")
category = "general"
entries.append({
"name": name,
+4 -4
View File
@@ -71,15 +71,15 @@ const TIER_ORDER = ["all", "official", "community"];
// grouped sections; keep it in sync with CATALOG_CATEGORIES in
// hermes_cli/plugin_catalog.py and website/scripts/extract-plugins.py.
const CATEGORY_CONFIG: Record<string, { label: string; icon: string; blurb: string }> = {
memory: { label: "Memory", icon: "\u{1F9E0}", blurb: "Memory providers and context engines" },
desktop: { label: "Desktop", icon: "\u{1F5A5}\u{FE0F}", blurb: "Panes, tabs and views for Hermes Desktop" },
memory: { label: "Memory", icon: "\u{1F9E0}", blurb: "Memory providers and context engines" },
platform: { label: "Platforms", icon: "\u{1F4AC}", blurb: "Messaging and channel adapters" },
web: { label: "Web & Browser", icon: "\u{1F310}", blurb: "Search backends, extraction and browser control" },
tools: { label: "Tools", icon: "\u{1F6E0}\u{FE0F}", blurb: "New tools the agent can call" },
voice: { label: "Voice", icon: "\u{1F399}\u{FE0F}", blurb: "Speech, TTS and realtime audio" },
automation: { label: "Automation", icon: "\u{23F1}\u{FE0F}", blurb: "Hooks, wake triggers and session automation" },
models: { label: "Models", icon: "\u{2728}", blurb: "Model and inference providers" },
other: { label: "Other", icon: "\u{1F4E6}", blurb: "Everything else" },
general: { label: "General", icon: "\u{1F4E6}", blurb: "Plugins that span several areas" },
};
const CATEGORY_ORDER = Object.keys(CATEGORY_CONFIG);
@@ -465,7 +465,7 @@ export default function PluginCatalogPage() {
if (search.trim() || categoryFilter !== "all") return null;
const buckets = new Map<string, CatalogPlugin[]>();
for (const p of filtered) {
const key = CATEGORY_CONFIG[p.category] ? p.category : "other";
const key = CATEGORY_CONFIG[p.category] ? p.category : "general";
(buckets.get(key) ?? buckets.set(key, []).get(key)!).push(p);
}
return CATEGORY_ORDER.filter((c) => buckets.has(c)).map((c) => [c, buckets.get(c)!] as const);
@@ -475,7 +475,7 @@ export default function PluginCatalogPage() {
const counts: Record<string, number> = {};
for (const p of allPlugins) {
if (tierFilter !== "all" && p.tier !== tierFilter) continue;
const key = CATEGORY_CONFIG[p.category] ? p.category : "other";
const key = CATEGORY_CONFIG[p.category] ? p.category : "general";
counts[key] = (counts[key] || 0) + 1;
}
return counts;