diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7992a..d294365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added - **Per-call project routing on every `bm_*` tool.** All eight tools now accept optional `project` (name) and `project_id` (UUID from `bm_projects`) parameters. The agent can write or read against a project other than the Hermes-configured one — useful when the user asks to write into a different cloud project (e.g. a personal `main` project) without reconfiguring the plugin. `project_id` takes precedence over `project`; both fall back to the configured default when omitted. Workspace routing is handled transparently by BM via `project_id` — no separate workspace parameter is needed. +- **`bm_projects` and `bm_workspaces` agent tools.** Promotes the discovery logic previously available only as `/bm-project` and `/bm-workspace` slash commands to agent-facing tools. `bm_projects` returns JSON with `name` and `external_id` (UUID) per project so the agent can hand the UUID to `bm_write` / `bm_read` / etc. via `project_id` — the unambiguous form across cloud workspaces. `bm_workspaces` lists BM Cloud workspaces (name, type, role, default flag). Together with per-call routing, these unblock the workflow Drew's friction note flagged: agent picks the right project + workspace before writing, instead of silently operating against the active Hermes memory project. ### Notes -- This is the first of four planned improvements from a real-world friction note ("Hermes Basic Memory Cloud Task Experience"). Follow-ups will: promote `/bm-project` and `/bm-workspace` to agent-facing tools (`bm_projects`, `bm_workspaces`); add a `bm_import` tool for file-on-disk → BM-note in one call; and update SKILL.md with a worked cross-project workflow. +- Addresses the routing and discovery friction in the real-world note "Hermes Basic Memory Cloud Task Experience." A SKILL.md update with the worked discovery → route → write → verify workflow is the remaining follow-up. A proposed `bm_import` tool was evaluated and dropped — `read_file` + `bm_write` already composes the same operation with no new capability, at the cost of one more tool in the surface. +- The slash commands `/bm-project` and `/bm-workspace` still exist and behave identically — they continue to call `list_memory_projects` / `list_workspaces` directly via the actor. No behavior change for human use. ## [0.2.0] — 2026-05-11 diff --git a/__init__.py b/__init__.py index 4359195..4827e94 100644 --- a/__init__.py +++ b/__init__.py @@ -73,8 +73,15 @@ "bm_delete": "delete_note", "bm_move": "move_note", "bm_recent": "recent_activity", + "bm_projects": "list_memory_projects", + "bm_workspaces": "list_workspaces", } +# Discovery tools that operate across all projects/workspaces. They don't +# accept project/project_id args (no per-call routing) and the user-facing +# schemas omit those properties. +_GLOBAL_TOOLS: frozenset = frozenset({"bm_projects", "bm_workspaces"}) + TOOL_SCHEMAS: List[Dict[str, Any]] = [ { "name": "bm_search", @@ -198,6 +205,29 @@ }, }, }, + { + "name": "bm_projects", + "description": ( + "List all available Basic Memory projects (local + cloud). Returns " + "JSON with name and `external_id` (UUID) per project. Use the UUID " + "as `project_id` on other bm_* tools for unambiguous routing across " + "cloud workspaces. Call this when the user names a project that " + "isn't the active one, or when you need to disambiguate same-name " + "projects." + ), + "parameters": {"type": "object", "properties": {}}, + }, + { + "name": "bm_workspaces", + "description": ( + "List Basic Memory Cloud workspaces the user belongs to. Workspaces " + "are a BM Cloud concept; local mode returns just the personal " + "workspace. Returns JSON with name, type, role, and default flag. " + "Pair with bm_projects to disambiguate when the same project name " + "exists in multiple workspaces." + ), + "parameters": {"type": "object", "properties": {}}, + }, ] @@ -227,6 +257,10 @@ } for _schema in TOOL_SCHEMAS: + if _schema["name"] in _GLOBAL_TOOLS: + # Discovery tools (bm_projects, bm_workspaces) list everything — + # they don't take per-call routing. + continue _schema["parameters"]["properties"].update(_PROJECT_ROUTING_PROPS) @@ -618,14 +652,18 @@ def _translate_args( # the unambiguous form across cloud workspaces — preferred when project # names might collide between workspaces. Only one of the two reaches # BM so server-side precedence rules don't enter the picture. - project_id_override = args.get("project_id") - project_name_override = args.get("project") - if project_id_override: - out["project_id"] = str(project_id_override) - elif project_name_override: - out["project"] = str(project_name_override) - else: - out["project"] = default_project + # + # Global discovery tools (bm_projects, bm_workspaces) list everything + # and don't take routing args at all — skip the block for them. + if hermes_tool not in _GLOBAL_TOOLS: + project_id_override = args.get("project_id") + project_name_override = args.get("project") + if project_id_override: + out["project_id"] = str(project_id_override) + elif project_name_override: + out["project"] = str(project_name_override) + else: + out["project"] = default_project if hermes_tool == "bm_search": out["query"] = args["query"] @@ -663,6 +701,10 @@ def _translate_args( out["page_size"] = int(args["limit"]) if args.get("type"): out["type"] = args["type"] + elif hermes_tool in _GLOBAL_TOOLS: + # The agent needs to parse identifiers (UUIDs, workspace slugs) out + # of the response, so request JSON regardless of BM's text default. + out["output_format"] = "json" return bm_tool, out @@ -886,8 +928,13 @@ def system_prompt_block(self) -> str: "maintenance\n" "- `bm_recent(timeframe)` — list notes updated within a window " "(default 7d) when there's no specific query yet\n" + "- `bm_projects()` — list available projects (local + cloud) with " + "their UUIDs; call when the user names a project that isn't the " + "active one\n" + "- `bm_workspaces()` — list BM Cloud workspaces; pair with " + "`bm_projects` to disambiguate same-named projects\n" "\n" - "**Cross-project routing.** Every tool accepts optional `project` " + "**Cross-project routing.** Read/write tools accept optional `project` " "(name) or `project_id` (UUID). Omit both to use the active " f"project (`{self._project}`). Use `project_id` (from `bm_projects`) " "when the same project name exists in multiple cloud workspaces." diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 5eecc0b..4c780ab 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -355,7 +355,8 @@ def test_translate_routing_coerces_to_string(bm): ("bm_recent", {}), ]) def test_translate_routing_works_for_every_tool(bm, tool, base_args): - """Routing applies uniformly across all eight tools.""" + """Routing applies uniformly across every per-project tool. Global + discovery tools (bm_projects, bm_workspaces) are tested separately.""" args_with = dict(base_args, project="main") _, out = bm._translate_args(tool, args_with, "default-proj") assert out["project"] == "main" @@ -369,15 +370,59 @@ def test_translate_routing_works_for_every_tool(bm, tool, base_args): assert out["project"] == "default-proj" +# ---- Global discovery tools (bm_projects, bm_workspaces) ---- + +def test_translate_bm_projects_no_routing(bm): + """bm_projects is a global discovery tool — it lists across all projects + and workspaces. _translate_args must NOT inject a default project + (would make BM scope the listing) and MUST request JSON so the agent + can parse identifiers out of the response.""" + tool, out = bm._translate_args("bm_projects", {}, "default-proj") + assert tool == "list_memory_projects" + assert "project" not in out + assert "project_id" not in out + assert out == {"output_format": "json"} + + +def test_translate_bm_workspaces_no_routing(bm): + tool, out = bm._translate_args("bm_workspaces", {}, "default-proj") + assert tool == "list_workspaces" + assert "project" not in out + assert "project_id" not in out + assert out == {"output_format": "json"} + + +def test_translate_global_tools_ignore_project_kwargs(bm): + """Even if a confused caller passes project/project_id to a global tool, + those args are dropped — BM doesn't accept them and silently scoping + the listing would be worse than ignoring the args.""" + _, out = bm._translate_args( + "bm_projects", + {"project": "main", "project_id": "uuid-1"}, + "default-proj", + ) + assert "project" not in out + assert "project_id" not in out + + # ---- TOOL_SCHEMAS routing properties ---- def test_every_tool_schema_advertises_project_routing(bm): - """Every bm_* tool must expose `project` and `project_id` so the agent - sees them in the tool surface. Regression: forgetting to add routing - props to a new tool would silently lock the agent into the active - project — exactly the friction Drew's note flagged.""" + """Every per-project bm_* tool must expose `project` and `project_id` so + the agent sees them in the tool surface. Regression: forgetting to add + routing props to a new tool would silently lock the agent into the + active project — exactly the friction Drew's note flagged. + + Global discovery tools (bm_projects, bm_workspaces) are excluded — they + list across projects/workspaces and don't take routing args.""" for schema in bm.TOOL_SCHEMAS: props = schema["parameters"]["properties"] + if schema["name"] in bm._GLOBAL_TOOLS: + assert "project" not in props, \ + f"{schema['name']} is a global tool; should not have project prop" + assert "project_id" not in props, \ + f"{schema['name']} is a global tool; should not have project_id prop" + continue assert "project" in props, f"{schema['name']} missing project prop" assert "project_id" in props, f"{schema['name']} missing project_id prop" # Routing is always optional — never in `required`. @@ -405,7 +450,8 @@ 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_recent"} + "bm_context", "bm_delete", "bm_move", "bm_recent", + "bm_projects", "bm_workspaces"} assert names == expected diff --git a/tests/test_provider.py b/tests/test_provider.py index 318a582..87871d8 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -52,18 +52,19 @@ 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 8 schemas + # Fresh provider, never initialized, should still expose all 10 schemas p = bm.BasicMemoryProvider() assert p._initialized is False schemas = p.get_tool_schemas() - assert len(schemas) == 8 + assert len(schemas) == 10 names = {s["name"] for s in schemas} assert names == {"bm_search", "bm_read", "bm_write", "bm_edit", - "bm_context", "bm_delete", "bm_move", "bm_recent"} + "bm_context", "bm_delete", "bm_move", "bm_recent", + "bm_projects", "bm_workspaces"} - # Initialized provider also returns 8 (idempotent) + # Initialized provider also returns 10 (idempotent) p._initialized = True - assert len(p.get_tool_schemas()) == 8 + assert len(p.get_tool_schemas()) == 10 def test_get_tool_schemas_returns_independent_copies(bm): @@ -71,7 +72,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()) == 8 + assert len(p.get_tool_schemas()) == 10 def test_handle_tool_call_uninitialized(bm):