From 70cd432a63ded07c90d8dd577082f8524469a760 Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Tue, 12 May 2026 14:19:20 -0500 Subject: [PATCH 1/3] feat: bm_projects and bm_workspaces agent tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the discovery logic previously available only as /bm-project and /bm-workspace slash commands to agent-facing tools. Adds two entries to _HERMES_TO_BM (→ list_memory_projects, list_workspaces) and corresponding TOOL_SCHEMAS entries. These are global discovery tools — they list across all projects and workspaces, so they don't take routing args. A new _GLOBAL_TOOLS frozenset gates the per-tool routing logic in both directions: the post-construction loop on TOOL_SCHEMAS skips them when adding the routing properties, and _translate_args skips the routing block for them. Both tools request output_format=json since the agent needs to parse identifiers (UUIDs, workspace slugs) out of the response. system_prompt_block now lists the two new tools so the agent reaches for them when the user names a project that isn't the configured one. Together with the project_id routing landed in #4, these unblock the flow Drew's friction note flagged: the agent can now bm_projects() → pick by name+workspace → bm_write(... project_id=) instead of silently operating against the active Hermes memory project. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 ++++++ __init__.py | 65 ++++++++++++++++++++++++++++++++++++------ tests/test_helpers.py | 58 +++++++++++++++++++++++++++++++++---- tests/test_provider.py | 13 +++++---- 4 files changed, 124 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7992a..98f1710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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.4.0] — 2026-05-12 + +### Added +- **`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 they 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 +- Second of four planned improvements from the cross-project friction note. Next up: a `bm_import` tool for file-on-disk → BM-note in one call, and a SKILL.md update with the worked discovery → route → write → verify workflow. +- 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.3.0] — 2026-05-12 ### Added 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): From 9f7bc08ec6ff2887deba215ee1293a0e6234562a Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Tue, 12 May 2026 14:31:16 -0500 Subject: [PATCH 2/3] changelog: roll bm_projects/bm_workspaces entry into 0.3.0 This PR ships alongside the project_id routing changes in the same 0.3.0 release rather than its own minor bump. Merge the two entries into a single 0.3.0 section. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f1710..cef17e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,22 +4,15 @@ 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.4.0] — 2026-05-12 - -### Added -- **`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 they 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 -- Second of four planned improvements from the cross-project friction note. Next up: a `bm_import` tool for file-on-disk → BM-note in one call, and a SKILL.md update with the worked discovery → route → write → verify workflow. -- 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.3.0] — 2026-05-12 ### 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. +- First two of four planned improvements from a real-world friction note ("Hermes Basic Memory Cloud Task Experience"). Follow-ups: add a `bm_import` tool for file-on-disk → BM-note in one call; update SKILL.md with the worked discovery → route → write → verify workflow. +- 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 From 3e5c0e1687a44c003927c816a6ced5a36df71e45 Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Tue, 12 May 2026 14:35:09 -0500 Subject: [PATCH 3/3] changelog: drop bm_import from follow-ups, note the eval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After evaluating bm_import: no new capability over read_file + bm_write (now that #4 added project_id routing). Adding it would burn a tool slot for what's already a clean two-call composition. Documenting the read → write pattern in SKILL.md is the better fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cef17e5..d294365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **`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 -- First two of four planned improvements from a real-world friction note ("Hermes Basic Memory Cloud Task Experience"). Follow-ups: add a `bm_import` tool for file-on-disk → BM-note in one call; update SKILL.md with the worked discovery → route → write → verify 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