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:
@@ -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://")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user