diff --git a/CHANGELOG.md b/CHANGELOG.md index f596fdf..f775489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] — 2026-05-11 + +### Added +- **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. +- **`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`. +- **`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. + +### Fixed +- **`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. + +### Notes +- `/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`. +- `/bm-workspace` short-circuits in local mode with a one-line explanation. Workspaces are a BM Cloud concept. +- Mid-session project/workspace switching is intentionally not supported in 0.2.0 — auto-capture would land in unexpected places. Tracked as a follow-up. + ## [0.1.7] — 2026-05-10 ### Changed diff --git a/README.md b/README.md index 46db27d..79309c5 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Expected: ## What the agent gets -Seven tools (curated subset of Basic Memory's MCP surface): +Eight tools (curated subset of Basic Memory's MCP surface): | Tool | Use | |---|---| @@ -58,6 +58,7 @@ Seven tools (curated subset of Basic Memory's MCP surface): | `bm_context` | Navigate via `memory://` URLs to find related notes | | `bm_delete` | Delete a note | | `bm_move` | Move a note to a different folder | +| `bm_recent` | List notes updated recently (default `7d`; accepts natural-language timeframes) | Plus automatic capture: - **Per turn**: every user/assistant exchange appends to a running session-transcript note @@ -65,6 +66,32 @@ Plus automatic capture: A bundled skill (`skill:view basic-memory:basic-memory`) gives the agent a longer reference doc on top of the always-on `system_prompt_block`. +## Slash commands + +For direct, in-session use without going through the agent (requires Hermes ≥ v0.11.0): + +| Command | Use | +|---|---| +| `/bm-search ` | Search the knowledge graph; returns compact title/permalink/preview rows. | +| `/bm-read ` | Read a note by title, permalink, or `memory://` URL. | +| `/bm-context ` | Build context for a note (target + related). | +| `/bm-recent [timeframe]` | Recently updated notes. Default `7d`; accepts `"2 weeks"`, `"yesterday"`, etc. | +| `/bm-status` | Plugin/provider state: mode, project, capture flags, bm CLI path. | +| `/bm-remember ` | Capture a quick note. Title = first line (≤80 chars), folder = `remember_folder` (default `bm-remember`), tagged `manual-capture`. | +| `/bm-project` | List all known projects; the active one is marked. | +| `/bm-workspace` | List BM Cloud workspaces. Cloud mode only — prints an explanatory line in local mode. | + +Examples: + +```text +/bm-search Q3 OKRs +/bm-read decisions/auth-rewrite +/bm-recent yesterday +/bm-remember Reminder: switch the staging job to the new image after the rebase lands. +``` + +`/bm-project` and `/bm-workspace` are read-only in 0.2.0 — mid-session switching is intentionally not supported because auto-capture would otherwise land in the wrong place. Tracked as a follow-up. + ## Configuration Defaults are reasonable for local use: @@ -77,6 +104,7 @@ Defaults are reasonable for local use: | `capture_folder` | `hermes-sessions` | Folder within the project for session notes | | `capture_per_turn` | `true` | Append every turn to a session transcript | | `capture_session_end` | `true` | Write a summary note when the session ends | +| `remember_folder` | `bm-remember` | Folder where `/bm-remember` captures land (kept separate from session transcripts) | To override, write `~/.hermes/basic-memory.json` or run `hermes memory setup basic-memory`: @@ -87,7 +115,8 @@ To override, write `~/.hermes/basic-memory.json` or run `hermes memory setup bas "project_path": "~/hermes-memory/", "capture_per_turn": true, "capture_session_end": true, - "capture_folder": "hermes-sessions" + "capture_folder": "hermes-sessions", + "remember_folder": "bm-remember" } ``` diff --git a/__init__.py b/__init__.py index 691722c..7b8b32d 100644 --- a/__init__.py +++ b/__init__.py @@ -31,14 +31,14 @@ from datetime import datetime, timezone from pathlib import Path from shutil import which -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple # Hermes ABC + helpers — these resolve because Hermes adds its tree to sys.path # when loading plugins (same pattern as plugins/memory/mem0/__init__.py:21). from agent.memory_provider import MemoryProvider from tools.registry import tool_error -__version__ = "0.1.7" +__version__ = "0.2.0" logger = logging.getLogger("hermes.memory.basic-memory") @@ -72,6 +72,7 @@ "bm_context": "build_context", "bm_delete": "delete_note", "bm_move": "move_note", + "bm_recent": "recent_activity", } TOOL_SCHEMAS: List[Dict[str, Any]] = [ @@ -175,6 +176,28 @@ "required": ["identifier", "new_folder"], }, }, + { + "name": "bm_recent", + "description": ( + "List notes updated recently. Use to surface what's been touched " + "without a specific search query." + ), + "parameters": { + "type": "object", + "properties": { + "timeframe": { + "type": "string", + "description": "Lookback window. Accepts '7d', '2 weeks', 'yesterday', etc.", + "default": "7d", + }, + "limit": {"type": "integer", "description": "Max results (default 10).", "default": 10}, + "type": { + "type": "string", + "description": "Optional filter by item type (e.g. 'entity', 'observation').", + }, + }, + }, + }, ] @@ -588,6 +611,13 @@ def _translate_args( elif hermes_tool == "bm_move": out["identifier"] = args["identifier"] out["destination_folder"] = args["new_folder"] + elif hermes_tool == "bm_recent": + if args.get("timeframe"): + out["timeframe"] = str(args["timeframe"]) + if args.get("limit") is not None: + out["page_size"] = int(args["limit"]) + if args.get("type"): + out["type"] = args["type"] return bm_tool, out @@ -606,6 +636,7 @@ def __init__(self) -> None: self._capture_per_turn: bool = True self._capture_session_end: bool = True self._capture_folder: str = "hermes-sessions" + self._remember_folder: str = "bm-remember" self._session_id: str = "" self._hermes_home: str = "" self._session_note_id: Optional[str] = None @@ -649,6 +680,7 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: self._capture_per_turn = bool(_coerce_bool(cfg.get("capture_per_turn", True))) self._capture_session_end = bool(_coerce_bool(cfg.get("capture_session_end", True))) self._capture_folder = cfg.get("capture_folder") or "hermes-sessions" + self._remember_folder = cfg.get("remember_folder") or "bm-remember" # Bootstrap-install bm via uv if it's not already on disk. One-time cost # on a fresh machine; idempotent no-op once basic-memory is installed. @@ -799,7 +831,9 @@ def system_prompt_block(self) -> str: "- `bm_edit(identifier, operation, content)` — append, prepend, " "find_replace, replace_section\n" "- `bm_delete(identifier)` / `bm_move(identifier, new_folder)` — " - "maintenance" + "maintenance\n" + "- `bm_recent(timeframe)` — list notes updated within a window " + "(default 7d) when there's no specific query yet" ) def get_tool_schemas(self) -> List[Dict[str, Any]]: @@ -1082,6 +1116,11 @@ def get_config_schema(self) -> List[Dict[str, Any]]: "description": "BM folder where session notes land", "default": "hermes-sessions", }, + { + "key": "remember_folder", + "description": "BM folder where /bm-remember captures land", + "default": "bm-remember", + }, ] def save_config(self, values: Dict[str, Any], hermes_home: str) -> None: @@ -1117,6 +1156,459 @@ def _is_circuit_open(self) -> bool: return False +# --------------------------------------------------------------------------- +# Slash commands — /bm-* surface for CLI/gateway sessions +# --------------------------------------------------------------------------- +# +# Plugin-owned slash commands let humans run BM operations without going +# through the agent. Handlers are sync `(raw_args: str) -> str` closures over +# a provider instance; output is printed verbatim by Hermes. We catch our own +# exceptions and return a plain-text message — Hermes also catches but yields +# a generic "Plugin command error: ..." line that hides detail from the user. + + +_SLASH_USAGE: Dict[str, str] = { + "bm-search": "Usage: /bm-search \nSearch the Basic Memory knowledge graph.", + "bm-read": "Usage: /bm-read ", + "bm-context": "Usage: /bm-context ", + "bm-recent": "Usage: /bm-recent [timeframe] (default: 7d. Accepts '2 weeks', 'yesterday', etc.)", + "bm-status": "Usage: /bm-status", + "bm-remember": "Usage: /bm-remember \nCapture a note. First line becomes the title.", + "bm-project": "Usage: /bm-project (lists Basic Memory projects; active one marked)", + "bm-workspace": "Usage: /bm-workspace (lists Basic Memory Cloud workspaces; cloud mode only)", +} + + +def _is_help_arg(raw_args: str) -> bool: + s = raw_args.strip() + return s in ("help", "-h", "--help", "?") + + +def _unwrap_json_or_text(raw: str) -> Any: + """ + Best-effort decode of a tool result. Returns: + - the inner JSON if `raw` parses to JSON (and unwraps {"text": "..."} when + the inner string is also JSON) + - the raw string otherwise + """ + if not isinstance(raw, str) or not raw: + return raw + try: + data = json.loads(raw) + except Exception: + return raw + if isinstance(data, dict) and "text" in data and isinstance(data["text"], str): + inner = data["text"] + try: + inner_data = json.loads(inner) + return inner_data + except Exception: + return inner + return data + + +def _format_result_rows(items: List[Any], header: str, empty_msg: str) -> str: + if not items: + return empty_msg + lines = [header] + for r in items: + if not isinstance(r, dict): + continue + title = str(r.get("title") or r.get("name") or "(untitled)") + permalink = str(r.get("permalink") or r.get("path") or "") + preview_raw = r.get("content") or r.get("preview") or r.get("snippet") or "" + if not isinstance(preview_raw, str): + preview_raw = str(preview_raw) + preview = re.sub(r"\s+", " ", preview_raw)[:200].strip() + bits = [f"- {title}"] + if permalink: + bits.append(f"({permalink})") + if preview: + bits.append(f"— {preview}") + lines.append(" ".join(bits)) + return "\n".join(lines) + + +def _slash_uninit(cmd: str) -> str: + return f"{cmd}: basic-memory provider not initialized. Run `hermes memory status` to diagnose." + + +def _remember_title(text: str) -> str: + """Derive a note title from free-form text. First non-empty line, ≤80 chars.""" + for line in text.splitlines(): + line = line.strip().lstrip("#").strip() + if line: + return line[:80] + # Fallback: timestamp + return "Note " + datetime.now(timezone.utc).strftime("%Y-%m-%d %H%M UTC") + + +def _build_slash_commands( + provider: "BasicMemoryProvider", +) -> List[Tuple[str, Callable[[str], str], str, str]]: + """ + Return (name, handler, description, args_hint) tuples for each /bm-* command. + Handlers close over `provider` to access the live actor and config. + """ + + def _bm_search(raw_args: str) -> str: + args = raw_args.strip() + if not args or _is_help_arg(args): + return _SLASH_USAGE["bm-search"] + if not provider._initialized or provider._actor is None: + return _slash_uninit("bm-search") + try: + raw = provider._actor.call( + "search_notes", + { + "project": provider._project, + "query": args, + "page_size": 10, + "output_format": "json", + }, + timeout=15.0, + ) + except Exception as e: + return f"bm-search: {e}" + data = _unwrap_json_or_text(raw) + results = data.get("results") if isinstance(data, dict) else None + return _format_result_rows( + list(results or []), + header=f"Basic Memory results for {args!r}:", + empty_msg=f"No results for {args!r}.", + ) + + def _bm_read(raw_args: str) -> str: + args = raw_args.strip() + if not args or _is_help_arg(args): + return _SLASH_USAGE["bm-read"] + if not provider._initialized or provider._actor is None: + return _slash_uninit("bm-read") + try: + raw = provider._actor.call( + "read_note", + {"project": provider._project, "identifier": args}, + timeout=15.0, + ) + except Exception as e: + return f"bm-read: {e}" + body = _unwrap_json_or_text(raw) + if isinstance(body, dict) and "error" in body: + return f"bm-read: {body['error']}" + return body if isinstance(body, str) else json.dumps(body, indent=2) + + def _bm_context(raw_args: str) -> str: + args = raw_args.strip() + if not args or _is_help_arg(args): + return _SLASH_USAGE["bm-context"] + if not provider._initialized or provider._actor is None: + return _slash_uninit("bm-context") + try: + raw = provider._actor.call( + "build_context", + {"project": provider._project, "url": args, "depth": 1}, + timeout=15.0, + ) + except Exception as e: + return f"bm-context: {e}" + body = _unwrap_json_or_text(raw) + if isinstance(body, dict) and "error" in body: + return f"bm-context: {body['error']}" + return body if isinstance(body, str) else json.dumps(body, indent=2) + + def _bm_recent(raw_args: str) -> str: + args = raw_args.strip() + if _is_help_arg(args): + return _SLASH_USAGE["bm-recent"] + timeframe = args or "7d" + if not provider._initialized or provider._actor is None: + return _slash_uninit("bm-recent") + try: + raw = provider._actor.call( + "recent_activity", + { + "project": provider._project, + "timeframe": timeframe, + "page_size": 10, + "output_format": "json", + }, + timeout=15.0, + ) + except Exception as e: + return f"bm-recent: {e}" + data = _unwrap_json_or_text(raw) + results: List[Any] = [] + if isinstance(data, list): + # BM's recent_activity returns `list[dict]` in JSON mode — that's + # the documented signature (`-> str | list[dict]`). + results = data + elif isinstance(data, dict): + # Older BM versions or wrapping layers may bury the rows under a key. + for key in ("results", "items", "activity", "primary_results"): + val = data.get(key) + if isinstance(val, list): + results = val + break + return _format_result_rows( + results, + header=f"Basic Memory activity ({timeframe}):", + empty_msg=f"No activity in the last {timeframe}.", + ) + + def _bm_status(raw_args: str) -> str: + if _is_help_arg(raw_args): + return _SLASH_USAGE["bm-status"] + lines = [ + "Basic Memory plugin status", + f" Provider: {provider.name}", + f" Mode: {provider._mode}", + f" Project: {provider._project}", + ] + if provider._mode == "local": + lines.append(f" Path: {provider._project_path}") + bm_bin = _bm_binary_path() + lines.append(f" bm CLI: {bm_bin or '(not found)'}") + lines.append(f" MCP module: {'available' if _MCP_AVAILABLE else 'missing'}") + lines.append(f" Initialized: {'yes' if provider._initialized else 'no'}") + lines.append( + f" Capture: per-turn={provider._capture_per_turn}, " + f"session-end={provider._capture_session_end}, " + f"folder={provider._capture_folder!r}" + ) + lines.append(f" Remember folder: {provider._remember_folder!r}") + if provider._failure_count: + circuit = "open" if provider._is_circuit_open() else "closed" + lines.append(f" Failures: {provider._failure_count} (circuit {circuit})") + return "\n".join(lines) + + def _bm_remember(raw_args: str) -> str: + text = raw_args.strip() + if not text or _is_help_arg(text): + return _SLASH_USAGE["bm-remember"] + if not provider._initialized or provider._actor is None: + return _slash_uninit("bm-remember") + title = _remember_title(text) + folder = provider._remember_folder or "bm-remember" + try: + raw = provider._actor.call( + "write_note", + { + "project": provider._project, + "title": title, + "directory": folder, + "content": text, + "tags": ["manual-capture", _hostname()], + "output_format": "json", + }, + timeout=15.0, + ) + except Exception as e: + return f"bm-remember: {e}" + permalink = _extract_permalink(raw, fallback=title) + return f"Saved: {title}\n Folder: {folder}\n Permalink: {permalink}" + + def _bm_project(raw_args: str) -> str: + if _is_help_arg(raw_args): + return _SLASH_USAGE["bm-project"] + if not provider._initialized or provider._actor is None: + return _slash_uninit("bm-project") + try: + raw = provider._actor.call( + "list_memory_projects", + {"output_format": "json"}, + timeout=15.0, + ) + except Exception as e: + return f"bm-project: {e}" + data = _unwrap_json_or_text(raw) + projects: List[Any] = [] + if isinstance(data, dict): + for key in ("projects", "results", "items"): + val = data.get(key) + if isinstance(val, list): + projects = val + break + if not projects and isinstance(data, list): + projects = data + if not projects: + return "No Basic Memory projects found." + lines = ["Basic Memory projects:"] + for p in projects: + if isinstance(p, dict): + name = str(p.get("name") or p.get("permalink") or "(unnamed)") + src = p.get("source") or p.get("workspace") or "" + else: + name, src = str(p), "" + marker = " (active)" if name == provider._project else "" + tag = f" [{src}]" if src else "" + lines.append(f"- {name}{tag}{marker}") + return "\n".join(lines) + + def _bm_workspace(raw_args: str) -> str: + if _is_help_arg(raw_args): + return _SLASH_USAGE["bm-workspace"] + if provider._mode != "cloud": + return ( + "Workspaces are a Basic Memory Cloud concept. " + f"This plugin is in '{provider._mode}' mode — no workspaces to list." + ) + if not provider._initialized or provider._actor is None: + return _slash_uninit("bm-workspace") + try: + raw = provider._actor.call( + "list_workspaces", + {"output_format": "json"}, + timeout=15.0, + ) + except Exception as e: + return f"bm-workspace: {e}" + data = _unwrap_json_or_text(raw) + workspaces: List[Any] = [] + if isinstance(data, dict): + for key in ("workspaces", "results", "items"): + val = data.get(key) + if isinstance(val, list): + workspaces = val + break + if not workspaces: + return "No Basic Memory Cloud workspaces found." + lines = ["Basic Memory Cloud workspaces:"] + for w in workspaces: + if isinstance(w, dict): + name = str(w.get("name") or w.get("slug") or "(unnamed)") + wtype = w.get("workspace_type") or "" + role = w.get("role") or "" + is_default = bool(w.get("is_default")) + else: + name, wtype, role, is_default = str(w), "", "", False + bits = [f"- {name}"] + tag_parts = [x for x in (wtype, role) if x] + if tag_parts: + bits.append(f"[{' / '.join(tag_parts)}]") + if is_default: + bits.append("(default)") + lines.append(" ".join(bits)) + return "\n".join(lines) + + return [ + ("bm-search", _bm_search, "Search Basic Memory.", ""), + ("bm-read", _bm_read, "Read a Basic Memory note.", ""), + ("bm-context", _bm_context, "Show context graph for a Basic Memory note.", ""), + ("bm-recent", _bm_recent, "Show recent Basic Memory activity.", "[timeframe]"), + ("bm-status", _bm_status, "Show the Basic Memory plugin status.", ""), + ("bm-remember", _bm_remember, "Save a quick note to Basic Memory.", ""), + ("bm-project", _bm_project, "List Basic Memory projects.", ""), + ("bm-workspace", _bm_workspace, "List Basic Memory Cloud workspaces.", ""), + ] + + +# --------------------------------------------------------------------------- +# PluginManager reach-in — workaround for Hermes's memory-provider collector +# --------------------------------------------------------------------------- +# +# Hermes loads memory-provider plugins through a stripped-down `_ProviderCollector` +# context (plugins/memory/__init__.py) that only captures `register_memory_provider`; +# `register_command` and `register_skill` are not delegated. The result is that +# `ctx.register_command(...)` and `ctx.register_skill(...)` calls in this plugin +# silently no-op in real installs, even though Hermes's PluginManager *does* +# expose working slash-command and skill registries (used by general plugins). +# +# The clean fix lives upstream — a ~15-line patch to teach `_ProviderCollector` +# to delegate to PluginManager. Until that lands, we write to PluginManager's +# registries ourselves, matching exactly the entry shape and normalization +# `PluginContext.register_command` / `register_skill` produce. Idempotent with +# the future upstream fix: both code paths write identical entries to the same +# dicts. +# +# 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 our register() cannot re-enter us. + +_PLUGIN_MANIFEST_NAME = "basic-memory" + +_SKILL_DESCRIPTION = ( + "Reference for using bm_* tools and the Basic Memory knowledge graph " + "(search-before-answer, capture decisions, navigate via memory:// URLs)." +) + + +def _register_via_plugin_manager( + provider: "BasicMemoryProvider", + skill_path: Optional[Path] = None, +) -> None: + """ + Reach into Hermes's PluginManager to register slash commands and the + bundled skill, bypassing the memory-provider collector's no-op stubs. + + Best-effort: any failure (Hermes not on path, internal API renamed, + discovery errored) logs at debug/warning and degrades to "no slash + commands" rather than breaking memory-provider registration. + """ + try: + from hermes_cli.plugins import _ensure_plugins_discovered + except Exception as e: + logger.debug( + "basic-memory: hermes_cli.plugins unavailable (%s); skipping " + "slash-command reach-in", + e, + ) + return + + try: + mgr = _ensure_plugins_discovered() + except Exception as e: + logger.warning("basic-memory: PluginManager discovery failed: %s", e) + return + + # Mirror PluginContext.register_command's name-conflict guard against + # built-in commands. Best-effort: if the import path changed, skip the + # check rather than dropping every command. + try: + from hermes_cli.commands import resolve_command # type: ignore + except Exception: + resolve_command = None # type: ignore[assignment] + + plugin_commands = getattr(mgr, "_plugin_commands", None) + if plugin_commands is None: + logger.debug( + "basic-memory: PluginManager has no _plugin_commands attr; " + "slash commands skipped" + ) + else: + for name, handler, description, args_hint in _build_slash_commands(provider): + # Mirror Hermes's normalization (plugins.py:426). + clean = name.lower().strip().lstrip("/").replace(" ", "-") + if not clean: + continue + if resolve_command is not None: + try: + if resolve_command(clean) is not None: + logger.warning( + "basic-memory: skipping /%s — conflicts with " + "a built-in command", + clean, + ) + continue + except Exception: + pass + plugin_commands[clean] = { + "handler": handler, + "description": description or "Plugin command", + "plugin": _PLUGIN_MANIFEST_NAME, + "args_hint": (args_hint or "").strip(), + } + + plugin_skills = getattr(mgr, "_plugin_skills", None) + if plugin_skills is not None and skill_path is not None and skill_path.exists(): + plugin_skills[f"{_PLUGIN_MANIFEST_NAME}:basic-memory"] = { + "path": skill_path, + "plugin": _PLUGIN_MANIFEST_NAME, + "bare_name": "basic-memory", + "description": _SKILL_DESCRIPTION, + } + + # --------------------------------------------------------------------------- # atexit safety net (mirrors plugins/memory/openviking pattern) # --------------------------------------------------------------------------- @@ -1152,15 +1644,40 @@ def register(ctx: Any) -> None: # through `system_prompt_block()`. skill_path = Path(__file__).resolve().parent / "skill" / "SKILL.md" if skill_path.exists() and hasattr(ctx, "register_skill"): + # Forward-compat: if Hermes's memory-provider collector ever delegates + # register_skill to PluginManager (or another loader passes us a real + # PluginContext), this lands the skill via the supported path. The + # reach-in below covers the current production collector either way. try: ctx.register_skill( "basic-memory", skill_path, - description=( - "Reference for using bm_* tools and the Basic Memory " - "knowledge graph (search-before-answer, capture decisions, " - "navigate via memory:// URLs)." - ), + description=_SKILL_DESCRIPTION, ) except Exception as e: logger.warning("basic-memory: register_skill failed: %s", e) + + # Forward-compat: when Hermes's memory-provider collector gains + # register_command (PR to NousResearch/hermes-agent pending), this is the + # right path. Until then, hasattr returns False and we fall through to + # the reach-in below. + if hasattr(ctx, "register_command"): + for name, handler, description, args_hint in _build_slash_commands(provider): + try: + ctx.register_command( + name, + handler, + description=description, + args_hint=args_hint, + ) + except Exception as e: + logger.warning( + "basic-memory: register_command(%s) failed: %s", name, e + ) + + # Write directly to PluginManager's registries. This is the production + # path today; see _register_via_plugin_manager docstring for the why. + _register_via_plugin_manager( + provider, + skill_path=skill_path if skill_path.exists() else None, + ) diff --git a/plugin.yaml b/plugin.yaml index b465299..d7a326b 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: basic-memory -version: 0.1.7 +version: 0.2.0 description: "Basic Memory — persistent knowledge graph backed by the basic-memory MCP server" pip_dependencies: - mcp diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..8c15102 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,652 @@ +""" +Tests for the plugin-owned /bm-* slash commands. + +Covers: +- registration through ctx.register_command (forward-compat path) +- PluginManager reach-in (production path with current Hermes collector) +- per-handler behavior: usage text, uninitialized provider, happy path, + and exception → plain-text error. +""" +from __future__ import annotations + +import json +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from .conftest import FakeSession, make_scripted_actor + + +# --------------------------------------------------------------------------- +# Helpers for the reach-in tests +# --------------------------------------------------------------------------- + + +class _ProviderCollectorLike: + """ + Mirror of Hermes's real `_ProviderCollector` shape: captures + `register_memory_provider` and no-ops everything else. NOT a MagicMock — + `hasattr(collector, "register_command")` must return False, matching the + real collector. + """ + + def __init__(self): + self.provider = None + + def register_memory_provider(self, provider): + self.provider = provider + + +class _FakePluginManager: + """Stand-in for hermes_cli.plugins.PluginManager — just the bits we touch.""" + + def __init__(self): + self._plugin_commands: dict = {} + self._plugin_skills: dict = {} + + +def _install_fake_hermes_cli(monkeypatch, *, resolve_returns=None): + """ + Insert a fake `hermes_cli.plugins` (with `_ensure_plugins_discovered`) and + `hermes_cli.commands` (with `resolve_command`) into sys.modules so the + reach-in's lazy imports resolve. Returns the FakePluginManager instance + so tests can assert against its registries. + + resolve_returns: optional mapping of command name → truthy/falsy value + the fake resolve_command should return. Use a truthy value to simulate a + built-in conflict for that name. + """ + fake_mgr = _FakePluginManager() + + plugins_mod = types.ModuleType("hermes_cli.plugins") + + def _ensure_plugins_discovered(force: bool = False): + return fake_mgr + + plugins_mod._ensure_plugins_discovered = _ensure_plugins_discovered # type: ignore[attr-defined] + + commands_mod = types.ModuleType("hermes_cli.commands") + + def _resolve_command(name: str): + if resolve_returns and name in resolve_returns: + return resolve_returns[name] + return None + + commands_mod.resolve_command = _resolve_command # type: ignore[attr-defined] + + hermes_cli = types.ModuleType("hermes_cli") + monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli) + monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins_mod) + monkeypatch.setitem(sys.modules, "hermes_cli.commands", commands_mod) + + return fake_mgr + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +_EXPECTED_COMMANDS = { + "bm-search", + "bm-read", + "bm-context", + "bm-recent", + "bm-status", + "bm-remember", + "bm-project", + "bm-workspace", +} + + +def test_register_wires_up_all_slash_commands_on_modern_ctx(bm): + """Forward-compat path: when ctx supports register_command (e.g. after the + upstream collector patch lands, or for plugins loaded via PluginContext), + every /bm-* command is registered through that path.""" + ctx = MagicMock() + bm._active_providers.clear() + bm.register(ctx) + names = {call.args[0] for call in ctx.register_command.call_args_list} + assert names == _EXPECTED_COMMANDS + bm._active_providers.clear() + + +def test_register_command_calls_include_description_and_args_hint(bm): + ctx = MagicMock() + bm._active_providers.clear() + bm.register(ctx) + for call in ctx.register_command.call_args_list: + name, handler = call.args[0], call.args[1] + kwargs = call.kwargs + assert callable(handler) + assert "description" in kwargs and kwargs["description"] + assert "args_hint" in kwargs # may be empty string for no-arg commands + bm._active_providers.clear() + + +def test_register_tolerates_old_hermes_without_register_command(bm): + """Plugins must not crash on Hermes < v0.11.0 (no register_command).""" + class _OldCtx: + def __init__(self): + self.memory_calls = [] + + def register_memory_provider(self, provider): + self.memory_calls.append(provider) + + ctx = _OldCtx() + bm._active_providers.clear() + bm.register(ctx) # must not raise + assert len(ctx.memory_calls) == 1 + bm._active_providers.clear() + + +def test_register_swallows_register_command_errors(bm, caplog): + """If one register_command call fails, the others — and provider + registration — still proceed.""" + ctx = MagicMock() + ctx.register_command.side_effect = ValueError("name collision with builtin") + bm._active_providers.clear() + with caplog.at_level("WARNING"): + bm.register(ctx) + ctx.register_memory_provider.assert_called_once() + assert ctx.register_command.call_count == len(_EXPECTED_COMMANDS) + assert "register_command" in caplog.text + bm._active_providers.clear() + + +# --------------------------------------------------------------------------- +# Reach-in (production path): ctx is the no-op _ProviderCollector +# --------------------------------------------------------------------------- + + +def test_reach_in_writes_all_commands_to_plugin_manager(bm, monkeypatch): + """Regression for Codex P1: with the real collector shape (no + register_command method), reach into PluginManager and write commands + directly. The unit suite previously used MagicMock, which masked this + silent-skip by making every attribute exist.""" + fake_mgr = _install_fake_hermes_cli(monkeypatch) + ctx = _ProviderCollectorLike() + assert not hasattr(ctx, "register_command"), \ + "test collector must mirror real _ProviderCollector — no register_command" + + bm._active_providers.clear() + bm.register(ctx) + try: + assert ctx.provider is not None # memory provider still registered + assert set(fake_mgr._plugin_commands.keys()) == _EXPECTED_COMMANDS + for name, entry in fake_mgr._plugin_commands.items(): + assert callable(entry["handler"]) + assert entry["plugin"] == "basic-memory" + assert "description" in entry + assert "args_hint" in entry + finally: + bm._active_providers.clear() + + +def test_reach_in_writes_skill_to_plugin_manager(bm, monkeypatch): + """Same silent-skip applies to register_skill — the bundled skill never + landed in real installs prior to this fix. Reach-in writes the namespaced + entry directly.""" + fake_mgr = _install_fake_hermes_cli(monkeypatch) + ctx = _ProviderCollectorLike() + bm._active_providers.clear() + bm.register(ctx) + try: + assert "basic-memory:basic-memory" in fake_mgr._plugin_skills + skill = fake_mgr._plugin_skills["basic-memory:basic-memory"] + assert skill["plugin"] == "basic-memory" + assert skill["bare_name"] == "basic-memory" + assert isinstance(skill["path"], Path) + assert skill["path"].name == "SKILL.md" + finally: + bm._active_providers.clear() + + +def test_reach_in_skips_command_conflicting_with_builtin(bm, monkeypatch, caplog): + """Mirror Hermes's PluginContext.register_command guard — when + resolve_command(name) returns a truthy value, skip that command and + log a warning rather than overwriting a built-in.""" + # Simulate /bm-search colliding with a built-in. + fake_mgr = _install_fake_hermes_cli( + monkeypatch, resolve_returns={"bm-search": object()} + ) + ctx = _ProviderCollectorLike() + bm._active_providers.clear() + with caplog.at_level("WARNING"): + bm.register(ctx) + try: + assert "bm-search" not in fake_mgr._plugin_commands + # Other commands still landed + assert "bm-read" in fake_mgr._plugin_commands + assert "conflicts with a built-in" in caplog.text + finally: + bm._active_providers.clear() + + +def test_reach_in_degrades_when_hermes_cli_missing(bm, monkeypatch, caplog): + """If hermes_cli.plugins isn't importable (e.g. running outside a Hermes + install), the reach-in must log and continue — never crash the plugin's + memory-provider registration.""" + # Don't install fake modules; force import to fail. + monkeypatch.setitem(sys.modules, "hermes_cli.plugins", None) # type: ignore[arg-type] + ctx = _ProviderCollectorLike() + bm._active_providers.clear() + with caplog.at_level("DEBUG"): + bm.register(ctx) # must not raise + try: + assert ctx.provider is not None + # Either DEBUG message logged or nothing — both acceptable degrade modes. + finally: + bm._active_providers.clear() + + +def test_reach_in_degrades_when_plugin_manager_missing_attrs(bm, monkeypatch): + """Forward-compat: if Hermes ever refactors _plugin_commands / + _plugin_skills away, the reach-in must not crash.""" + fake_mgr = _FakePluginManager() + # Strip the attrs to simulate the rename/refactor + del fake_mgr._plugin_commands + del fake_mgr._plugin_skills + + plugins_mod = types.ModuleType("hermes_cli.plugins") + plugins_mod._ensure_plugins_discovered = lambda force=False: fake_mgr # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins_mod) + + ctx = _ProviderCollectorLike() + bm._active_providers.clear() + bm.register(ctx) # must not raise + bm._active_providers.clear() + + +def test_reach_in_normalizes_command_names(bm, monkeypatch): + """Reach-in must mirror Hermes's name normalization (lowercase, strip, + leading slash removed, spaces → hyphens). All our names are already + canonical, so this is a defensive check on the transform itself.""" + fake_mgr = _install_fake_hermes_cli(monkeypatch) + ctx = _ProviderCollectorLike() + bm._active_providers.clear() + bm.register(ctx) + try: + for name in fake_mgr._plugin_commands: + assert name == name.lower() + assert not name.startswith("/") + assert " " not in name + finally: + bm._active_providers.clear() + + +def test_reach_in_entries_match_hermes_internal_shape(bm, monkeypatch): + """The dict shape PluginContext.register_command writes + (plugins.py:447-452) is the contract Hermes's dispatch reads. Our + reach-in must produce byte-identical entries.""" + fake_mgr = _install_fake_hermes_cli(monkeypatch) + ctx = _ProviderCollectorLike() + bm._active_providers.clear() + bm.register(ctx) + try: + entry = fake_mgr._plugin_commands["bm-search"] + assert set(entry.keys()) == {"handler", "description", "plugin", "args_hint"} + assert callable(entry["handler"]) + assert entry["description"] # non-empty string + assert entry["plugin"] == "basic-memory" + assert isinstance(entry["args_hint"], str) + finally: + bm._active_providers.clear() + + +# --------------------------------------------------------------------------- +# Per-handler tests +# --------------------------------------------------------------------------- + + +def _ready_provider(bm, session: FakeSession | None = None): + """Build a provider in 'initialized' state with a scripted actor.""" + provider = bm.BasicMemoryProvider() + actor = make_scripted_actor(bm, session=session) + actor.start() + provider._actor = actor + provider._initialized = True + provider._project = "test-proj" + return provider, actor + + +def _handlers_by_name(bm, provider): + return {name: handler for name, handler, _, _ in bm._build_slash_commands(provider)} + + +# ---- Usage strings ---- + +@pytest.mark.parametrize( + "name,args", + [ + ("bm-search", ""), + ("bm-search", "help"), + ("bm-read", ""), + ("bm-read", "-h"), + ("bm-context", ""), + ("bm-remember", ""), + ("bm-remember", "--help"), + # Commands that take no args use 'help' to surface their usage line + ("bm-recent", "help"), + ("bm-status", "help"), + ("bm-project", "help"), + ("bm-workspace", "help"), + ], +) +def test_usage_returned_for_empty_or_help_args(bm, name, args): + provider = bm.BasicMemoryProvider() # not initialized — usage path shouldn't need it + handlers = _handlers_by_name(bm, provider) + out = handlers[name](args) + assert isinstance(out, str) + assert out.lower().startswith("usage:") + + +# ---- Uninitialized provider ---- + +@pytest.mark.parametrize("name,args", [ + ("bm-search", "hello"), + ("bm-read", "some/note"), + ("bm-context", "memory://x"), + ("bm-recent", ""), + ("bm-remember", "a thought"), + ("bm-project", ""), +]) +def test_handler_uninitialized_returns_message(bm, name, args): + provider = bm.BasicMemoryProvider() + handlers = _handlers_by_name(bm, provider) + out = handlers[name](args) + assert "not initialized" in out + assert name in out # message includes command name + + +# ---- /bm-status ---- + +def test_bm_status_renders_provider_state(bm, monkeypatch): + provider = bm.BasicMemoryProvider() + provider._mode = "local" + provider._project = "demo" + provider._project_path = "/tmp/demo" + provider._capture_per_turn = True + provider._capture_session_end = False + provider._capture_folder = "transcripts" + provider._remember_folder = "inbox" + monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bin/bm") + out = _handlers_by_name(bm, provider)["bm-status"]("") + assert "demo" in out + assert "/tmp/demo" in out + assert "/fake/bin/bm" in out + assert "Initialized: no" in out + assert "transcripts" in out and "inbox" in out + + +# ---- /bm-search ---- + +def test_bm_search_happy_path(bm): + session = FakeSession() + session.stub( + "search_notes", + lambda args: { + "results": [ + {"title": "Decisions", "permalink": "decisions/foo", "content": "we chose X"}, + {"title": "Plan", "permalink": "plans/p1", "preview": "next quarter"}, + ] + }, + ) + provider, actor = _ready_provider(bm, session) + try: + out = _handlers_by_name(bm, provider)["bm-search"]("widgets") + assert "Decisions" in out + assert "decisions/foo" in out + assert "we chose X" in out + # Args sent to BM + call = session.calls[-1] + assert call[0] == "search_notes" + assert call[1]["query"] == "widgets" + assert call[1]["project"] == "test-proj" + assert call[1]["output_format"] == "json" + finally: + actor.shutdown() + + +def test_bm_search_empty_results(bm): + session = FakeSession(default_response={"results": []}) + provider, actor = _ready_provider(bm, session) + try: + out = _handlers_by_name(bm, provider)["bm-search"]("missing") + assert "No results" in out + assert "missing" in out + finally: + actor.shutdown() + + +def test_bm_search_actor_exception_returns_plain_string(bm): + session = FakeSession() + def _boom(_args): + raise RuntimeError("MCP transport closed") + session.stub("search_notes", _boom) + provider, actor = _ready_provider(bm, session) + try: + out = _handlers_by_name(bm, provider)["bm-search"]("anything") + assert isinstance(out, str) + assert out.startswith("bm-search:") + assert "MCP transport closed" in out + finally: + actor.shutdown() + + +# ---- /bm-read ---- + +def test_bm_read_returns_text_body(bm): + """BM's read_note returns markdown wrapped in {"text": "..."} once our + extractor wraps the non-JSON response. The handler should unwrap and + return the bare markdown.""" + session = FakeSession() + session.stub( + "read_note", + # FakeSession serializes whatever the handler returns; emit the JSON + # the wrapper would produce for a markdown response. + lambda args: {"text": "# Foo\n\nbody text"}, + ) + provider, actor = _ready_provider(bm, session) + try: + out = _handlers_by_name(bm, provider)["bm-read"]("foo") + assert out == "# Foo\n\nbody text" + assert session.calls[-1][1]["identifier"] == "foo" + finally: + actor.shutdown() + + +# ---- /bm-recent ---- + +def test_bm_recent_default_timeframe(bm): + session = FakeSession(default_response={"results": []}) + provider, actor = _ready_provider(bm, session) + try: + out = _handlers_by_name(bm, provider)["bm-recent"]("") + assert "7d" in out + assert session.calls[-1][1]["timeframe"] == "7d" + finally: + actor.shutdown() + + +def test_bm_recent_custom_timeframe(bm): + session = FakeSession() + session.stub( + "recent_activity", + lambda args: {"results": [{"title": "Recent thing", "permalink": "x/y"}]}, + ) + provider, actor = _ready_provider(bm, session) + try: + out = _handlers_by_name(bm, provider)["bm-recent"]("2 weeks") + assert "2 weeks" in out + assert "Recent thing" in out + assert session.calls[-1][1]["timeframe"] == "2 weeks" + finally: + actor.shutdown() + + +def test_bm_recent_bare_list_shape(bm): + """Regression: BM's `recent_activity(output_format="json")` returns a bare + `list[dict]` (signature: `-> str | list[dict]`), not a dict-with-results. + The handler must surface those rows, not report "no activity".""" + session = FakeSession() + session.stub( + "recent_activity", + lambda args: [ + {"title": "Edited yesterday", "permalink": "notes/a", "content": "blob"}, + {"title": "Edited 3d ago", "permalink": "notes/b"}, + ], + ) + provider, actor = _ready_provider(bm, session) + try: + out = _handlers_by_name(bm, provider)["bm-recent"]("") + assert "Edited yesterday" in out + assert "Edited 3d ago" in out + assert "No activity" not in out + finally: + actor.shutdown() + + +# ---- /bm-remember ---- + +def test_bm_remember_derives_title_from_first_line(bm): + captured = {} + def _write(args): + captured.update(args) + return {"permalink": "bm-remember/note-perm"} + session = FakeSession() + session.stub("write_note", _write) + provider, actor = _ready_provider(bm, session) + provider._remember_folder = "bm-remember" + try: + out = _handlers_by_name(bm, provider)["bm-remember"]( + "Quarterly OKR review notes\n\nWe agreed to ship X." + ) + assert "Saved:" in out + assert "bm-remember/note-perm" in out + assert captured["title"] == "Quarterly OKR review notes" + assert captured["directory"] == "bm-remember" + assert "manual-capture" in captured["tags"] + finally: + actor.shutdown() + + +def test_bm_remember_long_first_line_truncated_to_80(bm): + session = FakeSession(default_response={"permalink": "x"}) + provider, actor = _ready_provider(bm, session) + try: + long_line = "A" * 200 + _handlers_by_name(bm, provider)["bm-remember"](long_line) + title = session.calls[-1][1]["title"] + assert len(title) == 80 + finally: + actor.shutdown() + + +def test_bm_remember_uses_configured_folder(bm): + session = FakeSession(default_response={"permalink": "x"}) + provider, actor = _ready_provider(bm, session) + provider._remember_folder = "scratch" + try: + _handlers_by_name(bm, provider)["bm-remember"]("hello") + assert session.calls[-1][1]["directory"] == "scratch" + finally: + actor.shutdown() + + +# ---- /bm-project ---- + +def test_bm_project_lists_and_marks_active(bm): + session = FakeSession() + session.stub( + "list_memory_projects", + lambda args: { + "projects": [ + {"name": "other-proj"}, + {"name": "test-proj"}, + ] + }, + ) + provider, actor = _ready_provider(bm, session) + try: + out = _handlers_by_name(bm, provider)["bm-project"]("") + assert "other-proj" in out + assert "test-proj" in out + # Active project line includes the marker + active_line = next( + line for line in out.splitlines() if "test-proj" in line + ) + assert "active" in active_line + finally: + actor.shutdown() + + +# ---- /bm-workspace ---- + +def test_bm_workspace_local_mode_message(bm): + provider = bm.BasicMemoryProvider() + provider._mode = "local" + out = _handlers_by_name(bm, provider)["bm-workspace"]("") + assert "Cloud" in out + assert "local" in out + + +def test_bm_workspace_cloud_mode_lists(bm): + session = FakeSession() + session.stub( + "list_workspaces", + lambda args: { + "workspaces": [ + {"name": "Personal", "workspace_type": "personal", + "role": "owner", "is_default": True}, + {"name": "Acme", "workspace_type": "team", "role": "member"}, + ] + }, + ) + provider, actor = _ready_provider(bm, session) + provider._mode = "cloud" + try: + out = _handlers_by_name(bm, provider)["bm-workspace"]("") + assert "Personal" in out + assert "Acme" in out + assert "default" in out + finally: + actor.shutdown() + + +# ---- _unwrap_json_or_text helper ---- + +def test_unwrap_passes_through_raw_string(bm): + assert bm._unwrap_json_or_text("plain text") == "plain text" + + +def test_unwrap_returns_inner_json_when_text_wraps_json(bm): + outer = json.dumps({"text": json.dumps({"a": 1})}) + assert bm._unwrap_json_or_text(outer) == {"a": 1} + + +def test_unwrap_returns_text_value_when_inner_is_markdown(bm): + outer = json.dumps({"text": "# Heading\n\nbody"}) + assert bm._unwrap_json_or_text(outer) == "# Heading\n\nbody" + + +def test_unwrap_returns_dict_when_top_level_json(bm): + outer = json.dumps({"results": [1, 2]}) + assert bm._unwrap_json_or_text(outer) == {"results": [1, 2]} + + +# ---- _remember_title ---- + +def test_remember_title_strips_markdown_heading(bm): + assert bm._remember_title("# Decisions\n\nbody") == "Decisions" + + +def test_remember_title_skips_blank_lines(bm): + assert bm._remember_title("\n\nFirst real line\nrest") == "First real line" + + +def test_remember_title_falls_back_to_timestamp(bm): + title = bm._remember_title(" \n\n") + assert title.startswith("Note ") diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 9e805de..6dfef41 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -270,6 +270,27 @@ def test_translate_move(bm): } +def test_translate_recent_defaults(bm): + tool, args = bm._translate_args("bm_recent", {}, "proj") + assert tool == "recent_activity" + assert args == {"project": "proj"} + + +def test_translate_recent_full(bm): + tool, args = bm._translate_args( + "bm_recent", + {"timeframe": "2 weeks", "limit": 25, "type": "entity"}, + "proj", + ) + assert tool == "recent_activity" + assert args == { + "project": "proj", + "timeframe": "2 weeks", + "page_size": 25, + "type": "entity", + } + + # ---- _default_project / _hostname ---- def test_default_project_format(bm): @@ -289,7 +310,7 @@ def test_hostname_lowercased(bm, monkeypatch): def test_tool_schemas_complete(bm): names = {s["name"] for s in bm.TOOL_SCHEMAS} expected = {"bm_search", "bm_read", "bm_write", "bm_edit", - "bm_context", "bm_delete", "bm_move"} + "bm_context", "bm_delete", "bm_move", "bm_recent"} assert names == expected diff --git a/tests/test_provider.py b/tests/test_provider.py index 812f858..01c0974 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -52,18 +52,18 @@ def test_get_tool_schemas_unconditional(bm): subsequent bm_* invocation returns "Unknown tool: bm_*" forever. Schemas are static — return them unconditionally. """ - # Fresh provider, never initialized, should still expose all 7 schemas + # Fresh provider, never initialized, should still expose all 8 schemas p = bm.BasicMemoryProvider() assert p._initialized is False schemas = p.get_tool_schemas() - assert len(schemas) == 7 + assert len(schemas) == 8 names = {s["name"] for s in schemas} assert names == {"bm_search", "bm_read", "bm_write", "bm_edit", - "bm_context", "bm_delete", "bm_move"} + "bm_context", "bm_delete", "bm_move", "bm_recent"} - # Initialized provider also returns 7 (idempotent) + # Initialized provider also returns 8 (idempotent) p._initialized = True - assert len(p.get_tool_schemas()) == 7 + assert len(p.get_tool_schemas()) == 8 def test_get_tool_schemas_returns_independent_copies(bm): @@ -71,7 +71,7 @@ def test_get_tool_schemas_returns_independent_copies(bm): p = bm.BasicMemoryProvider() schemas = p.get_tool_schemas() schemas.clear() - assert len(p.get_tool_schemas()) == 7 + assert len(p.get_tool_schemas()) == 8 def test_handle_tool_call_uninitialized(bm):