Skip to content

Commit 6513a63

Browse files
groksrcclaude
andcommitted
Address Codex review: PluginManager reach-in + bm-recent list shape
Two fixes for the issues raised on PR #3. P1 — ctx.register_command and ctx.register_skill silently no-op in real Hermes installs. Memory-provider plugins are loaded through a stripped- down _ProviderCollector (plugins/memory/__init__.py) that captures only register_memory_provider; other registration methods are not delegated to PluginManager. The bundled SKILL.md has been hitting this same path since 0.1.5 — the call ran but the entry never landed. Workaround: write directly to PluginManager._plugin_commands and _plugin_skills from inside register(). Mirror the exact entry shape, name normalization, and built-in-conflict guard from PluginContext.register_command (plugins.py:401-453) and register_skill (plugins.py:622-665). When the upstream collector is patched, ctx.register_command/register_skill will write identical entries — the reach-in becomes a redundant idempotent overwrite. Recursion is safe: PluginManager.discover_and_load is idempotent (plugins.py:699) and explicitly skips memory-provider plugins at the manifest-routing stage (plugins.py:792-802), so calling _ensure_plugins_discovered() from inside register() cannot re-enter. New tests in tests/test_commands.py use a _ProviderCollector-shaped ctx (no register_command attribute) and assert the reach-in actually populates the fake PluginManager. The original tests used MagicMock, which masked the silent skip by making every attribute exist. P2 — bm_recent dropped real results. recent_activity(output_format= "json") returns a bare list[dict] per the BM signature; the handler only checked dict keys. Added the list branch + regression test matching the real JSON shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 70022da commit 6513a63

3 files changed

Lines changed: 369 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
77
## [0.2.0] — 2026-05-11
88

99
### Added
10-
- **Plugin-owned `/bm-*` slash commands** for CLI/gateway sessions. Eight commands give humans direct memory-graph access without going through the agent: `/bm-search`, `/bm-read`, `/bm-context`, `/bm-recent`, `/bm-status`, `/bm-remember`, `/bm-project`, `/bm-workspace`. Registered via `ctx.register_command(...)` (Hermes ≥ v0.11.0); older Hermes installs silently skip the slash surface. Closes #2.
10+
- **Plugin-owned `/bm-*` slash commands** for CLI/gateway sessions. Eight commands give humans direct memory-graph access without going through the agent: `/bm-search`, `/bm-read`, `/bm-context`, `/bm-recent`, `/bm-status`, `/bm-remember`, `/bm-project`, `/bm-workspace`. Closes #2.
1111
- **`bm_recent` tool** wrapping BM's `recent_activity`. Surfaces notes updated within a timeframe (`7d` default, accepts natural language like `"2 weeks"` or `"yesterday"`). Agent-facing and reused by `/bm-recent`.
1212
- **`remember_folder` config key** (default `"bm-remember"`). Separate from `capture_folder` so manual captures via `/bm-remember` don't intermix with auto-generated session transcripts. Notes are tagged `manual-capture` for further disambiguation.
1313

14+
### Fixed
15+
- **`ctx.register_skill(...)` was silently no-opping since 0.1.5** in real Hermes installs. Hermes loads memory-provider plugins through a stripped-down `_ProviderCollector` context (`plugins/memory/__init__.py`) that captures only `register_memory_provider`; `register_skill` and `register_command` are not delegated. The plugin now writes directly to `PluginManager._plugin_commands` and `_plugin_skills`, matching the entry shape and name normalization `PluginContext.register_command` / `register_skill` produce. This makes both the new slash commands and the bundled SKILL.md work in current Hermes installs. The clean fix lives upstream — a small patch to teach `_ProviderCollector` to delegate — and once that lands, the reach-in becomes a redundant double-write of identical entries. Forward-compat `ctx.register_command` / `ctx.register_skill` calls remain in place for the future code path.
16+
1417
### Notes
1518
- `/bm-remember` derives the title from the first non-empty line of the input, trimmed to 80 chars; falls back to `Note YYYY-MM-DD HHMM UTC`.
1619
- `/bm-workspace` short-circuits in local mode with a one-line explanation. Workspaces are a BM Cloud concept.

__init__.py

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,9 +1338,12 @@ def _bm_recent(raw_args: str) -> str:
13381338
return f"bm-recent: {e}"
13391339
data = _unwrap_json_or_text(raw)
13401340
results: List[Any] = []
1341-
if isinstance(data, dict):
1342-
# BM groups recent activity under several possible keys depending
1343-
# on version; check the common ones.
1341+
if isinstance(data, list):
1342+
# BM's recent_activity returns `list[dict]` in JSON mode — that's
1343+
# the documented signature (`-> str | list[dict]`).
1344+
results = data
1345+
elif isinstance(data, dict):
1346+
# Older BM versions or wrapping layers may bury the rows under a key.
13441347
for key in ("results", "items", "activity", "primary_results"):
13451348
val = data.get(key)
13461349
if isinstance(val, list):
@@ -1499,6 +1502,113 @@ def _bm_workspace(raw_args: str) -> str:
14991502
]
15001503

15011504

1505+
# ---------------------------------------------------------------------------
1506+
# PluginManager reach-in — workaround for Hermes's memory-provider collector
1507+
# ---------------------------------------------------------------------------
1508+
#
1509+
# Hermes loads memory-provider plugins through a stripped-down `_ProviderCollector`
1510+
# context (plugins/memory/__init__.py) that only captures `register_memory_provider`;
1511+
# `register_command` and `register_skill` are not delegated. The result is that
1512+
# `ctx.register_command(...)` and `ctx.register_skill(...)` calls in this plugin
1513+
# silently no-op in real installs, even though Hermes's PluginManager *does*
1514+
# expose working slash-command and skill registries (used by general plugins).
1515+
#
1516+
# The clean fix lives upstream — a ~15-line patch to teach `_ProviderCollector`
1517+
# to delegate to PluginManager. Until that lands, we write to PluginManager's
1518+
# registries ourselves, matching exactly the entry shape and normalization
1519+
# `PluginContext.register_command` / `register_skill` produce. Idempotent with
1520+
# the future upstream fix: both code paths write identical entries to the same
1521+
# dicts.
1522+
#
1523+
# Recursion is safe: PluginManager.discover_and_load is idempotent
1524+
# (plugins.py:699) and explicitly skips memory-provider plugins at the
1525+
# manifest-routing stage (plugins.py:792-802), so calling
1526+
# `_ensure_plugins_discovered()` from inside our register() cannot re-enter us.
1527+
1528+
_PLUGIN_MANIFEST_NAME = "basic-memory"
1529+
1530+
_SKILL_DESCRIPTION = (
1531+
"Reference for using bm_* tools and the Basic Memory knowledge graph "
1532+
"(search-before-answer, capture decisions, navigate via memory:// URLs)."
1533+
)
1534+
1535+
1536+
def _register_via_plugin_manager(
1537+
provider: "BasicMemoryProvider",
1538+
skill_path: Optional[Path] = None,
1539+
) -> None:
1540+
"""
1541+
Reach into Hermes's PluginManager to register slash commands and the
1542+
bundled skill, bypassing the memory-provider collector's no-op stubs.
1543+
1544+
Best-effort: any failure (Hermes not on path, internal API renamed,
1545+
discovery errored) logs at debug/warning and degrades to "no slash
1546+
commands" rather than breaking memory-provider registration.
1547+
"""
1548+
try:
1549+
from hermes_cli.plugins import _ensure_plugins_discovered
1550+
except Exception as e:
1551+
logger.debug(
1552+
"basic-memory: hermes_cli.plugins unavailable (%s); skipping "
1553+
"slash-command reach-in",
1554+
e,
1555+
)
1556+
return
1557+
1558+
try:
1559+
mgr = _ensure_plugins_discovered()
1560+
except Exception as e:
1561+
logger.warning("basic-memory: PluginManager discovery failed: %s", e)
1562+
return
1563+
1564+
# Mirror PluginContext.register_command's name-conflict guard against
1565+
# built-in commands. Best-effort: if the import path changed, skip the
1566+
# check rather than dropping every command.
1567+
try:
1568+
from hermes_cli.commands import resolve_command # type: ignore
1569+
except Exception:
1570+
resolve_command = None # type: ignore[assignment]
1571+
1572+
plugin_commands = getattr(mgr, "_plugin_commands", None)
1573+
if plugin_commands is None:
1574+
logger.debug(
1575+
"basic-memory: PluginManager has no _plugin_commands attr; "
1576+
"slash commands skipped"
1577+
)
1578+
else:
1579+
for name, handler, description, args_hint in _build_slash_commands(provider):
1580+
# Mirror Hermes's normalization (plugins.py:426).
1581+
clean = name.lower().strip().lstrip("/").replace(" ", "-")
1582+
if not clean:
1583+
continue
1584+
if resolve_command is not None:
1585+
try:
1586+
if resolve_command(clean) is not None:
1587+
logger.warning(
1588+
"basic-memory: skipping /%s — conflicts with "
1589+
"a built-in command",
1590+
clean,
1591+
)
1592+
continue
1593+
except Exception:
1594+
pass
1595+
plugin_commands[clean] = {
1596+
"handler": handler,
1597+
"description": description or "Plugin command",
1598+
"plugin": _PLUGIN_MANIFEST_NAME,
1599+
"args_hint": (args_hint or "").strip(),
1600+
}
1601+
1602+
plugin_skills = getattr(mgr, "_plugin_skills", None)
1603+
if plugin_skills is not None and skill_path is not None and skill_path.exists():
1604+
plugin_skills[f"{_PLUGIN_MANIFEST_NAME}:basic-memory"] = {
1605+
"path": skill_path,
1606+
"plugin": _PLUGIN_MANIFEST_NAME,
1607+
"bare_name": "basic-memory",
1608+
"description": _SKILL_DESCRIPTION,
1609+
}
1610+
1611+
15021612
# ---------------------------------------------------------------------------
15031613
# atexit safety net (mirrors plugins/memory/openviking pattern)
15041614
# ---------------------------------------------------------------------------
@@ -1534,22 +1644,23 @@ def register(ctx: Any) -> None:
15341644
# through `system_prompt_block()`.
15351645
skill_path = Path(__file__).resolve().parent / "skill" / "SKILL.md"
15361646
if skill_path.exists() and hasattr(ctx, "register_skill"):
1647+
# Forward-compat: if Hermes's memory-provider collector ever delegates
1648+
# register_skill to PluginManager (or another loader passes us a real
1649+
# PluginContext), this lands the skill via the supported path. The
1650+
# reach-in below covers the current production collector either way.
15371651
try:
15381652
ctx.register_skill(
15391653
"basic-memory",
15401654
skill_path,
1541-
description=(
1542-
"Reference for using bm_* tools and the Basic Memory "
1543-
"knowledge graph (search-before-answer, capture decisions, "
1544-
"navigate via memory:// URLs)."
1545-
),
1655+
description=_SKILL_DESCRIPTION,
15461656
)
15471657
except Exception as e:
15481658
logger.warning("basic-memory: register_skill failed: %s", e)
15491659

1550-
# Plugin-owned /bm-* slash commands. register_command landed in Hermes
1551-
# v0.11.0; the hasattr guard keeps older Hermes installs working — they
1552-
# just don't get the slash-command surface.
1660+
# Forward-compat: when Hermes's memory-provider collector gains
1661+
# register_command (PR to NousResearch/hermes-agent pending), this is the
1662+
# right path. Until then, hasattr returns False and we fall through to
1663+
# the reach-in below.
15531664
if hasattr(ctx, "register_command"):
15541665
for name, handler, description, args_hint in _build_slash_commands(provider):
15551666
try:
@@ -1563,3 +1674,10 @@ def register(ctx: Any) -> None:
15631674
logger.warning(
15641675
"basic-memory: register_command(%s) failed: %s", name, e
15651676
)
1677+
1678+
# Write directly to PluginManager's registries. This is the production
1679+
# path today; see _register_via_plugin_manager docstring for the why.
1680+
_register_via_plugin_manager(
1681+
provider,
1682+
skill_path=skill_path if skill_path.exists() else None,
1683+
)

0 commit comments

Comments
 (0)