diff --git a/src/basic_memory/api/app.py b/src/basic_memory/api/app.py index 2ba60b0f4..24ac3f6b3 100644 --- a/src/basic_memory/api/app.py +++ b/src/basic_memory/api/app.py @@ -182,7 +182,7 @@ async def entity_already_exists_error_handler(request: Request, exc: EntityAlrea request, HTTPException( status_code=409, - detail="Note already exists. Use edit_note to modify it, or delete it first.", + detail="Note already exists. Use bm_edit to modify it, or delete it first.", ), ) diff --git a/src/basic_memory/cli/commands/hook.py b/src/basic_memory/cli/commands/hook.py index 66595af4a..a20bf1788 100644 --- a/src/basic_memory/cli/commands/hook.py +++ b/src/basic_memory/cli/commands/hook.py @@ -159,7 +159,7 @@ class HarnessProfile: default_recall_prompt=( "You have Basic Memory available for this project. Before answering recall " 'questions ("what did we decide", "where did we leave off"), search the graph ' - "first — prefer structured filters (search_notes with type/status). When the " + "first — prefer structured filters (bm_search with type/status). When the " "user makes a material decision, capture it as a note with type: decision. " "Cite permalinks when referencing prior work." ), diff --git a/src/basic_memory/config_models.py b/src/basic_memory/config_models.py index ccfa011cf..abc75e9ac 100644 --- a/src/basic_memory/config_models.py +++ b/src/basic_memory/config_models.py @@ -410,7 +410,7 @@ def __init__(self, **data: Any) -> None: ... ) default_search_type: Literal["text", "vector", "hybrid"] | None = Field( default=None, - description="Default search type for search_notes when not specified per-query. " + description="Default search mode for bm_search when not specified per-query. " "Valid values: text, vector, hybrid. " "When unset, defaults to 'hybrid' if semantic search is enabled, otherwise 'text'.", ) @@ -591,8 +591,8 @@ def __init__(self, **data: Any) -> None: ... write_note_overwrite_default: bool = Field( default=False, description=( - "Default value for write_note's overwrite parameter. " - "When False (default), write_note errors if note already exists. " + "Default value for bm_write's overwrite parameter. " + "When False (default), bm_write errors if note already exists. " "Set to True to restore pre-v0.20 upsert behavior. " "Env: BASIC_MEMORY_WRITE_NOTE_OVERWRITE_DEFAULT" ), diff --git a/src/basic_memory/indexing/accepted_note_mutation_runner.py b/src/basic_memory/indexing/accepted_note_mutation_runner.py index 3591d037e..8c8c179c3 100644 --- a/src/basic_memory/indexing/accepted_note_mutation_runner.py +++ b/src/basic_memory/indexing/accepted_note_mutation_runner.py @@ -471,7 +471,7 @@ def accepted_note_integrity_rejection(error: IntegrityError) -> AcceptedNoteMuta if conflict_kind is RuntimeAcceptedNoteWriteConflictKind.file_path: return AcceptedNoteMutationRejection( kind=AcceptedNoteMutationRejectKind.conflict, - detail="Note already exists. Use edit_note to modify it, or delete it first.", + detail="Note already exists. Use bm_edit to modify it, or delete it first.", ) if conflict_kind is RuntimeAcceptedNoteWriteConflictKind.external_id: @@ -1641,7 +1641,7 @@ def reject_accepted_note_file_path_conflict( ): reject_accepted_note_mutation( AcceptedNoteMutationRejectKind.conflict, - "Note already exists. Use edit_note to modify it, or delete it first.", + "Note already exists. Use bm_edit to modify it, or delete it first.", ) diff --git a/src/basic_memory/man/man3/bm-read(3).md b/src/basic_memory/man/man3/bm-read(3).md index 2df769560..15bd9a238 100644 --- a/src/basic_memory/man/man3/bm-read(3).md +++ b/src/basic_memory/man/man3/bm-read(3).md @@ -23,6 +23,16 @@ bm_read(identifier, section=None, start_line=None, end_line=None, output_format="text", project=None) ``` +Cloud additionally supports the following hosted signature. These options are not +available on the local core tool: + +``` +bm_read(identifier, section=None, start_line=None, end_line=None, + max_tokens=None, include_frontmatter=True, include_content=True, + review_mode="structured", view="markdown", version=None, + versions=False, cursor=None, output_format="text", project=None) +``` + ## DESCRIPTION Returns one note's Markdown, frontmatter included. The identifier can be a file @@ -55,6 +65,22 @@ The shell counterpart is cat(1). - **output_format** (optional, default: "text") - **project** (string | null, optional, default: None) — Project name, 'workspace/project', or project external id. With multiple addressable projects, scope note and search calls explicitly with this parameter or a project-qualified target. +## CLOUD PARAMETERS + +Cloud's `bm_read` adds these parameters to the core parameters above: + +- **review_mode** ("structured" | "raw", optional, default: "structured") — Return prose with open review comments and suggestions as threads, or the stored bytes including review markup with "raw". +- **view** ("markdown" | "text", optional, default: "markdown") — "text" renders `::bm-*` components as Markdown and runs their live queries. Edit from the Markdown view. +- **version** (string | null, optional, default: None) — Read a stored note version, using a `version_id` from `versions=True`. +- **versions** (boolean, optional, default: False) — List stored note versions, newest first, instead of reading the note. +- **cursor** (string | null, optional, default: None) — With `versions=True`, continue from the previous page's `next_cursor`. + +A versions listing cannot be combined with a version, slices, `view="text"`, +or `include_content=False`. A cursor requires `versions=True`. Stored versions +and the text view must be read whole. The text view requires +`review_mode="structured"` and cannot read a stored version because live queries +show current data. Stored versions are available for Markdown notes only. + ## EXAMPLES ``` diff --git a/src/basic_memory/man/man3/build-context(3).md b/src/basic_memory/man/man3/build-context(3).md index 619952ce9..c89d4652d 100644 --- a/src/basic_memory/man/man3/build-context(3).md +++ b/src/basic_memory/man/man3/build-context(3).md @@ -67,7 +67,7 @@ Compact output is not a fixed token limit. - **page_size** (integer, optional, default: 10) — Number of primary results to return per page (default: 10, maximum: 50) - **max_related** (integer, optional, default: 10) — Maximum total related results to return (default: 10, maximum: 100) - **output_format** (string, optional, default: "json") — Response format - "json" for structured JSON dict, "text" for compact markdown text -- **compact** (boolean, optional, default: False) — Omit note bodies and entity observation lists for graph discovery; the server skips loading observations. Preserve identifiers, relation targets and pagination; use read_note for selected content. Not a guaranteed token budget. +- **compact** (boolean, optional, default: False) — Omit note bodies and entity observation lists for graph discovery; the server skips loading observations. Preserve identifiers, relation targets and pagination; use bm_read for selected content. Not a guaranteed token budget. ## MCP USAGE diff --git a/src/basic_memory/man/man3/edit-note(3).md b/src/basic_memory/man/man3/edit-note(3).md index 709a02fdb..ead411ef6 100644 --- a/src/basic_memory/man/man3/edit-note(3).md +++ b/src/basic_memory/man/man3/edit-note(3).md @@ -58,7 +58,7 @@ permalink, or memory:// URL — there is no fuzzy fallback for edits. ## PARAMETERS -- **identifier** (string, required) — The exact title, permalink, or memory:// URL of the note to edit. Must be an exact match - fuzzy matching is not supported for edit operations. Use search_notes() or read_note() first to find the correct identifier if uncertain. From the CLI this is a positional argument, not a flag. +- **identifier** (string, required) — The exact title, permalink, or memory:// URL of the note to edit. Must be an exact match - fuzzy matching is not supported for edit operations. Use bm_search() or bm_read() first to find the correct identifier if uncertain. From the CLI this is a positional argument, not a flag. - **operation** (string, required) — The editing operation to perform: - "append": Add content to the end of the note (creates the note if it doesn't exist) - "prepend": Add content to the beginning of the note (creates the note if it doesn't exist) - "find_replace": Replace occurrences of find_text with content (note must exist) - "replace_section": Replace a markdown section identified by its header (note and section must exist). The heading must match exactly, including trailing tags or text; a miss fails without writing. By default the section spans through the next heading of the same or higher level, so its subsections are replaced too; see replace_subsections. - "insert_before_section": Insert content before a section heading without consuming it (note must exist) - "insert_after_section": Insert content after a section heading without consuming it (note must exist) - **content** (string, required) — The content to add or use for replacement - **project** (string | null, optional, default: None) — Project name to edit in. Optional - server will resolve using hierarchy. Use "workspace/project" to route to a project in a specific cloud workspace. If unknown, use list_memory_projects() to discover available projects. @@ -69,7 +69,7 @@ permalink, or memory:// URL — there is no fuzzy fallback for edits. - **expected_replacements** (integer | null, optional, default: None) — For find_replace: required occurrence count of find_text (default 1). A mismatch fails without writing. - **replace_subsections** (boolean | null, optional, default: None) — For replace_section operation. Default (true): the section spans everything through the next heading of the same or higher level in the original note, so replacing "## Section" also replaces its "###" subsections — the replacement content may freely introduce new headings. Set to false to replace only the immediate content under the header, stopping at the next heading of any level and preserving subsections. - **metadata** (object | null, optional, default: None) — Optional dict of frontmatter fields to merge, independent of `operation`. Provided keys overwrite existing frontmatter values (or are added if new); unrelated frontmatter keys and the note body are left untouched. Can be combined with any operation in the same call. `title` and `permalink` are ignored since those have their own dedicated handling; `type` is applied like any other frontmatter field. Key deletion is not supported. -- **expected_checksum** (string | null, optional, default: None) — Optional revision precondition: the full `checksum` that read_note, write_note, or edit_note returned in JSON mode (the accepted revision's checksum; text output shows only its first 8 characters). When the note has changed since, the edit is refused with a revision conflict that names the current checksum, and nothing is written. A refused precondition never auto-creates the note. To refresh a stale checksum without reading the whole note, call read_note(identifier=..., include_content=False). +- **expected_checksum** (string | null, optional, default: None) — Optional revision precondition: the full `checksum` that bm_read, bm_write, or bm_edit returned in JSON mode (the accepted revision's checksum; text output shows only its first 8 characters). When the note has changed since, the edit is refused with a revision conflict that names the current checksum, and nothing is written. A refused precondition never auto-creates the note. To refresh a stale checksum without reading the whole note, call bm_read(identifier=..., include_content=False). - **output_format** (string, optional, default: "text") — "text" returns a markdown summary of the edit and the note's resulting observations and relations. "json" returns machine-readable edit metadata. ## MCP USAGE diff --git a/src/basic_memory/man/man3/read-note(3).md b/src/basic_memory/man/man3/read-note(3).md index e28bf9657..762ed22a6 100644 --- a/src/basic_memory/man/man3/read-note(3).md +++ b/src/basic_memory/man/man3/read-note(3).md @@ -66,7 +66,7 @@ Accepted identifier forms (all verified): - **include_frontmatter** (boolean, optional, default: False) — For unsliced JSON reads, include opening YAML in content; parsed frontmatter is returned either way. Explicit line ranges are never stripped. CLI: --frontmatter (--include-frontmatter is a deprecated alias). - **start_line** (integer | null, optional, default: None) — First document line to read (1-based, inclusive). Defaults to 1 when only end_line is given. Line scans count the full Markdown, including frontmatter, matching cat's default line coordinates. - **end_line** (integer | null, optional, default: None) — Last document line to read (inclusive); omitted means EOF. Out-of-file ranges return empty content; invalid/reversed ranges fail. With either bound, text output is numbered and JSON carries coordinates, has_more, and next_start_line/next_end_line. include_frontmatter does not strip an explicitly addressed range. Edits between calls may shift lines. -- **include_content** (boolean, optional, default: True) — Default true. Set to false to return only the note's metadata — title, permalink, file_path, the full accepted-revision checksum, and updated_at — without the body. Use it to refresh the checksum that edit_note/write_note take as expected_checksum without reading the whole note. Cannot be combined with start_line/end_line. +- **include_content** (boolean, optional, default: True) — Default true. Set to false to return only the note's metadata — title, permalink, file_path, the full accepted-revision checksum, and updated_at — without the body. Use it to refresh the checksum that bm_edit/bm_write take as expected_checksum without reading the whole note. Cannot be combined with start_line/end_line. ## MCP USAGE diff --git a/src/basic_memory/man/man3/write-note(3).md b/src/basic_memory/man/man3/write-note(3).md index 480738890..193d1d29c 100644 --- a/src/basic_memory/man/man3/write-note(3).md +++ b/src/basic_memory/man/man3/write-note(3).md @@ -73,8 +73,8 @@ the same shape as edit_note's conflict. Without it, - **tags** (array | string | null, optional, default: None) — Tags to categorize the note. Can be a list of strings, a comma-separated string, or None. Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3") - **note_type** (string, optional, default: "note") — Type of note to create (stored in frontmatter `type:`). Defaults to "note". Can be "guide", "report", "config", "person", etc. The CLI flag is --type. A `type:` in content frontmatter takes precedence over this parameter, and this is what schema validation keys on. - **metadata** (object | null, optional, default: None) — Optional dict of extra frontmatter fields merged into entity_metadata. Useful for schema notes or any note that needs custom YAML frontmatter beyond title/type/tags. Nested dicts are supported. Not available from the CLI. -- **overwrite** (boolean | null, optional, default: None) — If True, replace existing note on conflict. If False, error on conflict. If None (default), consult write_note_overwrite_default config setting. Overwrite replaces the body but merges frontmatter: keys the existing note has and this write does not set (custom fields) are kept, while keys this write sets (title, type, tags, metadata, or frontmatter in content) replace the old values. Existing frontmatter that cannot be parsed is discarded rather than merged. To remove a key, rewrite it with edit_note or edit the file. -- **expected_checksum** (string | null, optional, default: None) — Optional revision precondition for overwrite=True: the checksum of the note you read (from a JSON read_note, write_note or edit_note result). The note is replaced only while it is still that revision; otherwise the tool reports a revision conflict with the current checksum and changes nothing. Omit it to replace the note unconditionally. +- **overwrite** (boolean | null, optional, default: None) — If True, replace existing note on conflict. If False, error on conflict. If None (default), consult write_note_overwrite_default config setting. Overwrite replaces the body but merges frontmatter: keys the existing note has and this write does not set (custom fields) are kept, while keys this write sets (title, type, tags, metadata, or frontmatter in content) replace the old values. Existing frontmatter that cannot be parsed is discarded rather than merged. To remove a key, rewrite it with bm_edit or edit the file. +- **expected_checksum** (string | null, optional, default: None) — Optional revision precondition for overwrite=True: the checksum of the note you read (from a JSON bm_read, bm_write or bm_edit result). The note is replaced only while it is still that revision; otherwise the tool reports a revision conflict with the current checksum and changes nothing. Omit it to replace the note unconditionally. - **output_format** (string, optional, default: "text") — "text" returns a markdown summary. "json" returns machine-readable metadata. A refused write (note exists, revision conflict, moved target, disallowed directory) is a tool error; in JSON mode its message is the payload with action: "conflict" and an error code. ## MCP USAGE diff --git a/src/basic_memory/mcp/resources/man.py b/src/basic_memory/mcp/resources/man.py index 4f35650b1..d948d08c2 100644 --- a/src/basic_memory/mcp/resources/man.py +++ b/src/basic_memory/mcp/resources/man.py @@ -3,8 +3,8 @@ ``memory://man`` is the index (apropos); ``memory://man/`` is one page. Every bundled page is also registered as a concrete resource so clients that browse ``resources/list`` see each page with its summary, while the template underneath -accepts any spelling of a page reference — ``search-notes(3)``, ``3/search-notes``, -``search_notes`` — so an agent's first guess resolves. +accepts any spelling of a page reference — ``bm-search(3)``, ``3/bm-search``, +``bm_search`` — so an agent's first guess resolves. """ from fastmcp.exceptions import ResourceError @@ -35,9 +35,9 @@ async def manual_index() -> str: uri=MANUAL_PAGE_TEMPLATE, name="manual page", description=( - "One manual page, e.g. memory://man/search-notes(3). Section-3 pages document " + "One manual page, e.g. memory://man/bm-search(3). Section-3 pages document " "each MCP tool with parameters, verified examples, and gotchas. Any common " - "spelling of the page name resolves: search-notes(3), 3/search-notes, search_notes." + "spelling of the page name resolves: bm-search(3), 3/bm-search, bm_search." ), mime_type="text/markdown", ) diff --git a/src/basic_memory/mcp/resources/notes.py b/src/basic_memory/mcp/resources/notes.py index fa9fa73b2..5570ec158 100644 --- a/src/basic_memory/mcp/resources/notes.py +++ b/src/basic_memory/mcp/resources/notes.py @@ -93,7 +93,7 @@ async def read_note_markdown(identifier: str, context: Context | None) -> str: except ToolError as error: if "not found" in str(error).lower(): raise NoteNotFoundError( - f"No note {identifier!r}; search_notes can find the identifier" + f"No note {identifier!r}; bm_search can find the identifier" ) from error raise entity_id = resolved.json()["external_id"] diff --git a/src/basic_memory/mcp/tools/build_context.py b/src/basic_memory/mcp/tools/build_context.py index d468115f0..8fa906af0 100644 --- a/src/basic_memory/mcp/tools/build_context.py +++ b/src/basic_memory/mcp/tools/build_context.py @@ -251,7 +251,7 @@ async def build_context( bool, "Omit note bodies and entity observation lists for graph discovery; the server " "skips loading observations. Preserve identifiers, relation targets and " - "pagination; use read_note for selected content. Not a guaranteed token budget.", + "pagination; use bm_read for selected content. Not a guaranteed token budget.", ] = False, ) -> dict[str, Any] | str: """Get context needed to continue a discussion within a specific project. @@ -319,7 +319,7 @@ async def build_context( if page_size > MAX_CONTEXT_PAGE_SIZE: raise ValueError( f"page_size must be <= {MAX_CONTEXT_PAGE_SIZE}, got {page_size}. " - "build_context is a bounded traversal starting point; request another " + "bm_context is a bounded traversal starting point; request another " "page or follow the returned memory:// links." ) if max_related < 0: @@ -327,7 +327,7 @@ async def build_context( if max_related > MAX_CONTEXT_RELATED_RESULTS: raise ValueError( f"max_related must be <= {MAX_CONTEXT_RELATED_RESULTS}, got {max_related}. " - "build_context is a bounded traversal starting point; follow the returned " + "bm_context is a bounded traversal starting point; follow the returned " "memory:// links for more context." ) diff --git a/src/basic_memory/mcp/tools/delete_note.py b/src/basic_memory/mcp/tools/delete_note.py index 891a6a177..1f76da7f6 100644 --- a/src/basic_memory/mcp/tools/delete_note.py +++ b/src/basic_memory/mcp/tools/delete_note.py @@ -42,13 +42,13 @@ def _format_delete_error_response(project: str, error_message: str, identifier: It may already be deleted, the identifier may not match exactly, or the note may live in a different project. - delete_note needs an exact title or permalink. Alternate forms of this + bm_delete needs an exact title or permalink. Alternate forms of this identifier: title "{title_format}", permalink "{permalink_format}". Find the exact identifier with - `search_notes(query="{search_term}", project="{project}")` or - `list_directory(dir_name="/", project="{project}")`, then retry - `delete_note(identifier="...", project="{project}")`. + `bm_search(query="{search_term}", project="{project}")` or + `bm_list(path="/", project="{project}")`, then retry + `bm_delete(identifier="...", project="{project}")`. """).strip() # Permission/access errors @@ -59,7 +59,7 @@ def _format_delete_error_response(project: str, error_message: str, identifier: No write access to delete '{identifier}' in {project}: {error_message} The note may be open or locked by another application, or this account may - have read-only access to the project. `list_memory_projects()` shows the + have read-only access to the project. `bm_list(path="/")` shows the projects this account can reach. """).strip() @@ -71,7 +71,7 @@ def _format_delete_error_response(project: str, error_message: str, identifier: A system error occurred while deleting '{identifier}' in {project}: {error_message} The error may be transient (a locked file or low disk space). Check whether the - note still exists with `read_note(identifier="{identifier}", project="{project}")` + note still exists with `bm_read(identifier="{identifier}", project="{project}")` before retrying. If it persists, contact support@basicmemory.com. """).strip() @@ -84,7 +84,7 @@ def _format_delete_error_response(project: str, error_message: str, identifier: The file and the index may be out of sync, or another operation may hold a database lock. Check the note's current state with - `read_note(identifier="{identifier}", project="{project}")` before retrying. If + `bm_read(identifier="{identifier}", project="{project}")` before retrying. If the file is gone but the index still lists it, contact support@basicmemory.com. """).strip() @@ -95,7 +95,7 @@ def _format_delete_error_response(project: str, error_message: str, identifier: Error deleting note '{identifier}' in {project}: {error_message} Confirm the exact identifier with - `search_notes(query="{identifier}", project="{project}")` before retrying. If the + `bm_search(query="{identifier}", project="{project}")` before retrying. If the operation keeps failing, contact support@basicmemory.com. """).strip() @@ -302,17 +302,17 @@ async def delete_note( Error deleting directory '{identifier}': {str(e)} ## Troubleshooting: -1. **Verify the directory exists**: Use `list_directory("{identifier}")` to check +1. **Verify the directory exists**: Use `bm_list("{identifier}")` to check 2. **Check for permission issues**: Ensure you have delete access to the project 3. **Try individual deletes**: Delete files one at a time if bulk delete fails ## Alternative approach: ``` # List directory contents first -list_directory("{identifier}") +bm_list("{identifier}") # Then delete individual files -delete_note("path/to/file.md") +bm_delete("path/to/file.md") ```""", ) diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py index 9c69c255c..3b1c3d5c9 100644 --- a/src/basic_memory/mcp/tools/edit_note.py +++ b/src/basic_memory/mcp/tools/edit_note.py @@ -183,19 +183,18 @@ def _format_ambiguous_workspace_identifier_response( """Format the safe-stop response for ambiguous plain write identifiers.""" cleaned_identifier = identifier.strip() normalized_identifier = normalize_project_reference(cleaned_identifier).strip("/") - workspace_hint, project_hint, note_identifier = normalized_identifier.split("/", 2) + _, _, note_identifier = normalized_identifier.split("/", 2) return f"""# Edit Failed - Ambiguous Identifier `{cleaned_identifier}` could refer to a local note path in the active project, or to a note in `{detected_project}`. -Because edit_note changes content, Basic Memory will not infer a workspace route from a plain path. +Because bm_edit changes content, Basic Memory will not infer a workspace route from a plain path. Retry with one of these explicit routes: -- `edit_note(identifier="{note_identifier}", project="{detected_project}", operation=..., content=...)` -- `edit_note(identifier="{note_identifier}", workspace="{workspace_hint}", project="{project_hint}", operation=..., content=...)` -- `edit_note(identifier="memory://{normalized_identifier}", operation=..., content=...)` -- `edit_note(identifier="{note_identifier}", project_id="", operation=..., content=...)`""" +- `bm_edit(identifier="{note_identifier}", project="{detected_project}", command=..., content=...)` +- `bm_edit(identifier="memory://{normalized_identifier}", command=..., content=...)` +- `bm_edit(identifier="{note_identifier}", project="", command=..., content=...)`""" def _format_unresolved_project_route_response( @@ -211,8 +210,8 @@ def _format_unresolved_project_route_response( No note was edited or created. Basic Memory did not fall back to the active project `{active_project}`. ## How to retry -1. Use `list_memory_projects()` to confirm the workspace, project, and project ID. -2. Correct the `memory://` URL so its project route exists, or pass the note path with an explicit `project` or `project_id`. +1. Use `bm_list(path="/")` to confirm the workspace, project, and project ID. +2. Correct the `memory://` URL so its project route exists, or pass the note path with an explicit `project` (name, workspace/project, or external id). 3. If `{error.project_prefix}` is a directory in `{active_project}`, remove the `memory://` prefix and retry with `project="{active_project}"`.""" @@ -227,7 +226,7 @@ def _format_cross_project_entity_response( The identifier `{identifier}` resolved to a note outside the selected project `{active_project}`, so no changes were made. -Retry with `project_id="{target_project_id}"`, or use `list_memory_projects()` to confirm the intended project before editing.""" +Retry with `project="{target_project_id}"`, or use `bm_list(path="/")` to confirm the intended project before editing.""" class EditRefused(ToolError): @@ -265,7 +264,7 @@ def _unknown_outcome_guidance(identifier: str, project_external_id: str) -> str: return ( "The edit may have been applied. Read the note before retrying; repeating an " "append or prepend that already landed adds the content twice: " - f'read_note(identifier="{identifier}", project_id="{project_external_id}")' + f'bm_read(identifier="{identifier}", project="{project_external_id}")' ) @@ -305,14 +304,14 @@ def _format_revision_conflict_response( return f"""# Edit Failed - Note Revision Conflict The note '{identifier}' no longer exists, so nothing was edited. Look the note up again -before retrying, or drop expected_checksum to create it with append or prepend.""" +with bm_search before retrying; use bm_write to create a missing note.""" return f"""# Edit Failed - Note Revision Conflict The note '{identifier}' changed since you read it, so nothing was edited. Current checksum: `{current_checksum}` Re-read the note to see what changed (or fetch just its checksum with -`read_note(identifier="{identifier}", project_id="{project_external_id}", include_content=False)`), +`bm_read(identifier="{identifier}", project="{project_external_id}", include_content=False)`), then retry with expected_checksum="{current_checksum}". Drop expected_checksum to edit the current revision unconditionally.""" @@ -334,19 +333,19 @@ def _format_error_response( The note with identifier '{identifier}' could not be found. The `find_replace` and `replace_section` operations require an existing note with content to modify. -**Tip:** `append` and `prepend` operations automatically create the note if it doesn't exist. +**Tip:** Use bm_write to create a missing note before editing it. ## Suggestions to try: -1. **Use append/prepend instead**: These operations will create the note automatically if it doesn't exist -2. **Search for the note first**: Use `search_notes(query="{identifier.split("/")[-1]}", project="{project or "project-name"}")` to find similar notes with exact identifiers -3. **File exists on disk but is not indexed yet?**: edit_note indexes the file automatically when the identifier matches its path (e.g. 'folder/note' for 'folder/note.md'). If your identifier is a title or differs from the file path, run `basic-memory db reindex --search` or wait for the file watcher, then retry +1. **Create a missing note**: Use bm_write before calling bm_edit +2. **Search for the note first**: Use `bm_search(query="{identifier.split("/")[-1]}", project="{project or "project-name"}")` to find similar notes with exact identifiers +3. **File exists on disk but is not indexed yet?**: Run `basic-memory db reindex --search` or wait for the file watcher, then retry 4. **Try different exact identifier formats**: - If you used a permalink like "folder/note-title", try the exact title: "{identifier.split("/")[-1].replace("-", " ").title()}" - If you used a title, try the exact permalink format: "{identifier.lower().replace(" ", "-")}" - - Use `read_note(identifier="{identifier}", project="{project or "project-name"}")` first to verify the note exists and get the exact identifier + - Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` first to verify the note exists and get the exact identifier ## Alternative approach: -Use `write_note(title="title", content="content", directory="folder", project="{project or "project-name"}")` to create the note first, then edit it.""" +Use `bm_write(identifier="folder/title.md", content="content", project="{project or "project-name"}")` to create the note first, then edit it.""" # Find/replace specific errors if operation == "find_replace": @@ -356,7 +355,7 @@ def _format_error_response( The text '{find_text}' was not found in the note '{identifier}'. ## Suggestions to try: -1. **Read the note first**: Use `read_note(identifier="{identifier}", project="{project or "project-name"}")` to see the current content +1. **Read the note first**: Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to see the current content 2. **Check for exact matches**: The search is case-sensitive and must match exactly 3. **Try a broader search**: Search for just part of the text you want to replace 4. **Use expected_replacements=0**: If you want to verify the text doesn't exist @@ -377,13 +376,13 @@ def _format_error_response( Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}. ## How to fix: -1. **Read the note first**: Use `read_note(identifier="{identifier}", project="{project or "project-name"}")` to see how many times '{find_text}' appears -2. **Update expected_replacements**: Set expected_replacements={actual_count} in your edit_note call +1. **Read the note first**: Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to see how many times '{find_text}' appears +2. **Update expected_replacements**: Set expected_replacements={actual_count} in your bm_edit call 3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific ## Example: ``` -edit_note(identifier="{identifier}", operation="find_replace", content="new_text", project="{project or "project-name"}", find_text="{find_text}", expected_replacements={actual_count}) +bm_edit(identifier="{identifier}", command="find_replace", content="new_text", project="{project or "project-name"}", find_text="{find_text}", expected_replacements={actual_count}) ```""" # Section replacement errors @@ -393,7 +392,7 @@ def _format_error_response( Multiple sections found with the same header in note '{identifier}'. ## How to fix: -1. **Read the note first**: Use `read_note(identifier="{identifier}", project="{project or "project-name"}")` to see the document structure +1. **Read the note first**: Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to see the document structure 2. **Make headers unique**: Add more specific text to distinguish sections 3. **Use append instead**: Add content at the end rather than replacing a specific section @@ -409,14 +408,14 @@ def _format_error_response( There was a problem with the edit request to note '{identifier}': {error_message}. ## Common causes and fixes: -1. **Note doesn't exist**: Use `search_notes(query="query", project="{project or "project-name"}")` or `read_note(identifier="{identifier}", project="{project or "project-name"}")` to verify the note exists +1. **Note doesn't exist**: Use `bm_search(query="query", project="{project or "project-name"}")` or `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to verify the note exists 2. **Invalid identifier format**: Try different identifier formats (title vs permalink) 3. **Empty or invalid content**: Check that your content is properly formatted -4. **Server error**: Try the operation again, or use `read_note()` first to verify the note state +4. **Server error**: Try the operation again, or use `bm_read()` first to verify the note state ## Troubleshooting steps: -1. Verify the note exists: `read_note(identifier="{identifier}", project="{project or "project-name"}")` -2. If not found, search for it: `search_notes(query="{identifier.split("/")[-1]}", project="{project or "project-name"}")` +1. Verify the note exists: `bm_read(identifier="{identifier}", project="{project or "project-name"}")` +2. If not found, search for it: `bm_search(query="{identifier.split("/")[-1]}", project="{project or "project-name"}")` 3. Try again with the correct identifier from the search results""" # Fallback for other errors @@ -425,14 +424,14 @@ def _format_error_response( Error editing note '{identifier}': {error_message} ## General troubleshooting: -1. **Verify the note exists**: Use `read_note(identifier="{identifier}", project="{project or "project-name"}")` to check +1. **Verify the note exists**: Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to check 2. **Check your parameters**: Ensure all required parameters are provided correctly -3. **Read the note content first**: Use `read_note(identifier="{identifier}", project="{project or "project-name"}")` to understand the current structure +3. **Read the note content first**: Use `bm_read(identifier="{identifier}", project="{project or "project-name"}")` to understand the current structure 4. **Try a simpler operation**: Start with `append` if other operations fail ## Need help? -- Use `search_notes(query="query", project="{project or "project-name"}")` to find notes -- Use `read_note(identifier="identifier", project="{project or "project-name"}")` to examine content before editing +- Use `bm_search(query="query", project="{project or "project-name"}")` to find notes +- Use `bm_read(identifier="identifier", project="{project or "project-name"}")` to examine content before editing - Check that identifiers, section headers, and find_text match exactly""" @@ -511,7 +510,7 @@ async def edit_note( Args: identifier: The exact title, permalink, or memory:// URL of the note to edit. Must be an exact match - fuzzy matching is not supported for edit operations. - Use search_notes() or read_note() first to find the correct identifier if uncertain. + Use bm_search() or bm_read() first to find the correct identifier if uncertain. From the CLI this is a positional argument, not a flag. operation: The editing operation to perform: - "append": Add content to the end of the note (creates the note if it doesn't exist) @@ -550,12 +549,12 @@ async def edit_note( ignored since those have their own dedicated handling; `type` is applied like any other frontmatter field. Key deletion is not supported. expected_checksum: Optional revision precondition: the full `checksum` that - read_note, write_note, or edit_note returned in JSON mode (the accepted + bm_read, bm_write, or bm_edit returned in JSON mode (the accepted revision's checksum; text output shows only its first 8 characters). When the note has changed since, the edit is refused with a revision conflict that names the current checksum, and nothing is written. A refused precondition never auto-creates the note. To refresh a stale checksum without reading the - whole note, call read_note(identifier=..., include_content=False). + whole note, call bm_read(identifier=..., include_content=False). output_format: "text" returns a markdown summary of the edit and the note's resulting observations and relations. "json" returns machine-readable edit metadata. @@ -1137,6 +1136,9 @@ async def edit_note( "fileCreated": False, "error": "NOTE_REVISION_CONFLICT", "currentChecksum": current_checksum, + "message": _format_revision_conflict_response( + identifier, current_checksum, active_project.external_id + ), }, _format_revision_conflict_response( identifier, current_checksum, active_project.external_id diff --git a/src/basic_memory/mcp/tools/move_note.py b/src/basic_memory/mcp/tools/move_note.py index 0a9187267..91330bc0f 100644 --- a/src/basic_memory/mcp/tools/move_note.py +++ b/src/basic_memory/mcp/tools/move_note.py @@ -113,24 +113,24 @@ def _format_cross_project_error_response( ### Recommended approach: ``` # 1. Read the note content from current project - read_note("{identifier}") + bm_read("{identifier}") # 2. Create the note in the target project - write_note(title="Note Title", content="content from step 1", directory="target-folder", project="{target_project}") + bm_write(identifier="target-folder/note.md", content="content from step 1", project="{target_project}") # 3. Delete the original note if desired - delete_note("{identifier}", project="{current_project}") + bm_delete("{identifier}", project="{current_project}") ``` ### Alternative: Stay in current project If you want to move the note within the **{current_project}** project only: ``` - move_note(identifier="{identifier}", destination_path="new-folder/new-name.md") + bm_move(identifier="{identifier}", destination="new-folder/new-name.md") ``` ## Available projects: - Use `list_memory_projects()` to see all available projects. + Use `bm_list(path="/")` to see all available projects. """).strip() @@ -160,22 +160,22 @@ def _format_move_error_response(error_message: str, identifier: str, destination The note '{identifier}' could not be found for moving. Move operations require an exact match (no fuzzy matching). ## Suggestions to try: - 1. **Search for the note first**: Use `search_notes("{search_term}")` to find it with exact identifiers + 1. **Search for the note first**: Use `bm_search("{search_term}")` to find it with exact identifiers 2. **Try different exact identifier formats**: - If you used a permalink like "folder/note-title", try the exact title: "{title_format}" - If you used a title, try the exact permalink format: "{permalink_format}" - - Use `read_note()` first to verify the note exists and get the exact identifier + - Use `bm_read()` first to verify the note exists and get the exact identifier - 3. **List available notes**: Use `list_directory("/")` to see what notes exist in the current project - 4. **List available notes**: Use `list_directory("/")` to see what notes exist + 3. **List available notes**: Use `bm_list("/")` to see what notes exist in the current project + 4. **List available notes**: Use `bm_list("/")` to see what notes exist ## Before trying again: ``` # First, verify the note exists: - search_notes("{identifier}") + bm_search("{identifier}") # Then use the exact identifier from search results: - move_note(identifier="correct-identifier-here", destination_path="{destination_path}") + bm_move(identifier="correct-identifier-here", destination="{destination_path}") ``` """).strip() @@ -190,19 +190,19 @@ def _format_move_error_response(error_message: str, identifier: str, destination - Add timestamp: `{destination_path.rsplit(".", 1)[0] if "." in destination_path else destination_path}-backup.md` - Use different folder: `archive/{destination_path}` or `backup/{destination_path}` -2. **Check the existing file**: Use `read_note("{destination_path}")` to see what's already there +2. **Check the existing file**: Use `bm_read("{destination_path}")` to see what's already there 3. **Remove or rename existing**: If safe to do so, move the existing file first ## Try these alternatives: ``` # Option 1: Add timestamp to make unique -move_note(identifier="{identifier}", destination_path="{destination_path.rsplit(".", 1)[0] if "." in destination_path else destination_path}-backup.md") +bm_move(identifier="{identifier}", destination="{destination_path.rsplit(".", 1)[0] if "." in destination_path else destination_path}-backup.md") # Option 2: Use archive folder -move_note(identifier="{identifier}", destination_path="archive/{destination_path}") +bm_move(identifier="{identifier}", destination="archive/{destination_path}") # Option 3: Check what's at destination first -read_note("{destination_path}") +bm_read("{destination_path}") ```""" # Invalid path errors @@ -224,7 +224,7 @@ def _format_move_error_response(error_message: str, identifier: str, destination ## Try again with: ``` -move_note(identifier="{identifier}", destination_path="notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}") +bm_move(identifier="{identifier}", destination="notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}") ```""" # Permission/access errors @@ -243,8 +243,8 @@ def _format_move_error_response(error_message: str, identifier: str, destination 3. **Check file locks**: The file might be open in another application ## Alternative actions: -- List available projects: `list_memory_projects()` -- Try copying content instead: `read_note("{identifier}", project="project-name")` then `write_note()` to new location""" +- List available projects: `bm_list(path="/")` +- Try copying content instead: `bm_read("{identifier}", project="project-name")` then `bm_write()` to new location""" # Source file not found errors if "source" in error_message.lower() and ( @@ -257,18 +257,18 @@ def _format_move_error_response(error_message: str, identifier: str, destination This usually means the database and filesystem are out of sync. ## How to resolve: -1. **Check if note exists in database**: `read_note("{identifier}")` +1. **Check if note exists in database**: `bm_read("{identifier}")` 2. **Run sync operation**: The file might need to be re-synced 3. **Recreate the file**: If data exists in database, recreate the physical file ## Troubleshooting steps: ``` # Check if note exists in Basic Memory -read_note("{identifier}") +bm_read("{identifier}") # If it exists, the file is missing on disk - send a message to support@basicmachines.co # If it doesn't exist, use search to find the correct identifier -search_notes("{identifier}") +bm_search("{identifier}") ```""" # Server/filesystem errors @@ -287,20 +287,20 @@ def _format_move_error_response(error_message: str, identifier: str, destination 3. **Verify filesystem permissions**: Check if the destination directory is writable ## Alternative approaches: -- Copy content to new location: Use `read_note("{identifier}")` then `write_note()` +- Copy content to new location: Use `bm_read("{identifier}")` then `bm_write()` - Use a different destination folder that you know works - Send a message to support@basicmachines.co if the problem persists ## Backup approach: ``` # Read current content -content = read_note("{identifier}") +content = bm_read("{identifier}") # Create new note at desired location -write_note(title="New Note Title", content=content, directory="{destination_path.split("/")[0] if "/" in destination_path else "notes"}") +bm_write(identifier="{destination_path}", content=content) # Then delete original if successful -delete_note("{identifier}") +bm_delete("{identifier}") ```""" # Generic fallback @@ -310,7 +310,7 @@ def _format_move_error_response(error_message: str, identifier: str, destination Error moving '{identifier}' to '{destination_path}': {error_message} ## General troubleshooting: -1. **Verify the note exists**: `read_note("{identifier}")` or `search_notes("{identifier}")` +1. **Verify the note exists**: `bm_read("{identifier}")` or `bm_search("{identifier}")` 2. **Check destination path**: Ensure it's a valid relative path with `.md` extension 3. **Verify permissions**: Make sure you can edit files in this project 4. **Try a simpler path**: Use a basic folder structure like `notes/filename.md` @@ -318,10 +318,10 @@ def _format_move_error_response(error_message: str, identifier: str, destination ## Step-by-step approach: ``` # 1. Confirm note exists -read_note("{identifier}") +bm_read("{identifier}") # 2. Try a simple destination first -move_note(identifier="{identifier}", destination_path="notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}") +bm_move(identifier="{identifier}", destination="notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}") # 3. If that works, then try your original destination ``` @@ -330,13 +330,13 @@ def _format_move_error_response(error_message: str, identifier: str, destination If moving continues to fail, you can copy the content manually: ``` # Read current content -content = read_note("{identifier}") +content = bm_read("{identifier}") # Create new note -write_note(title="Title", content=content, directory="target-folder") +bm_write(identifier="target-folder/title.md", content=content) # Delete original once confirmed -delete_note("{identifier}") +bm_delete("{identifier}") ```""" ) @@ -554,7 +554,7 @@ async def move_note( ## Try again with a safe path: ``` -move_note(identifier="{identifier}", destination_path="notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}") +bm_move(identifier="{identifier}", destination="notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}") ```""" # Resolve every source before branching so file and directory mutations share @@ -654,17 +654,17 @@ async def move_note( Error moving directory '{identifier}' to '{destination_path}': {str(e)} ## Troubleshooting: -1. **Verify the directory exists**: Use `list_directory("{identifier}")` to check +1. **Verify the directory exists**: Use `bm_list("{identifier}")` to check 2. **Check for conflicts**: The destination may already contain files 3. **Try individual moves**: Move files one at a time if bulk move fails ## Alternative approach: ``` # List directory contents first -list_directory("{identifier}") +bm_list("{identifier}") # Then move individual files -move_note(identifier="path/to/file.md", destination_path="{destination_path}/file.md") +bm_move(identifier="path/to/file.md", destination="{destination_path}/file.md") ```""", ) @@ -842,7 +842,7 @@ async def _ensure_resolved_entity_id() -> str: ## Try again with a safe folder: ``` -move_note("{identifier}", destination_folder="notes") +bm_move("{identifier}", destination="notes/") ```""" # Trigger: caller asks to move a note to its current normalized file path. @@ -902,7 +902,7 @@ async def _ensure_resolved_entity_id() -> str: ## Try again with extension: ``` - move_note(identifier="{identifier}", destination_path="{destination_path}.{source_ext}") + bm_move(identifier="{identifier}", destination="{destination_path}.{source_ext}") ``` All examples in Basic Memory expect file extensions to be explicitly provided. @@ -961,7 +961,7 @@ async def _ensure_resolved_entity_id() -> str: ## Try again with matching extension: ``` - move_note(identifier="{identifier}", destination_path="{destination_path.rsplit(".", 1)[0]}.{source_ext}") + bm_move(identifier="{identifier}", destination="{destination_path.rsplit(".", 1)[0]}.{source_ext}") ``` """).strip() @@ -1019,13 +1019,13 @@ async def _ensure_resolved_entity_id() -> str: **Actual:** `{result.file_path}` This usually means the destination referenced a different - workspace/project, which move_note cannot do — notes can only be + workspace/project, which bm_move cannot do — notes can only be moved within the same project. To move content between projects: ``` - read_note("{result.file_path}") - write_note(title="Title", content="content", directory="folder", project="target-project") - delete_note("{result.file_path}", project="{active_project.name}") + bm_read("{result.file_path}") + bm_write(identifier="folder/title.md", content="content", project="target-project") + bm_delete("{result.file_path}", project="{active_project.name}") ``` """).strip() diff --git a/src/basic_memory/mcp/tools/read_content.py b/src/basic_memory/mcp/tools/read_content.py index 6823b9017..6619a7402 100644 --- a/src/basic_memory/mcp/tools/read_content.py +++ b/src/basic_memory/mcp/tools/read_content.py @@ -42,7 +42,7 @@ 'knowledge-graph processing. Text and JSON come back as {"type": "text"}; ' "images are re-encoded as JPEG and downscaled to about 350 KB; other binaries come " 'back base64 as {"type": "document"} and are refused above 350 KB. Use ' - "read_note for markdown notes when you want note resolution and fallback search." + "bm_read for markdown notes when you want note resolution and fallback search." ), tags={"notes"}, annotations={ diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index 73c90317d..1d409471c 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -168,7 +168,7 @@ async def read_note( include_content: Default true. Set to false to return only the note's metadata — title, permalink, file_path, the full accepted-revision checksum, and updated_at — without the body. Use it to refresh the checksum that - edit_note/write_note take as expected_checksum without reading the whole + bm_edit/bm_write take as expected_checksum without reading the whole note. Cannot be combined with start_line/end_line. context: Optional FastMCP context for performance caching. @@ -616,9 +616,9 @@ def format_not_found_message(project: str | None, identifier: str) -> str: No note matches "{identifier}" in {project}. Titles and permalinks must match exactly; title and text search found nothing either. - To look further, try `search_notes(query="{identifier}", project="{project}")` or - `recent_activity(timeframe="7d", project="{project}")`. If the user wants this note - created, use `write_note`. + To look further, try `bm_search(query="{identifier}", project="{project}")` or + `bm_search(since="7d", project="{project}")`. If the user wants this note + created, use `bm_write`. """) @@ -657,8 +657,8 @@ def format_related_results(project: str | None, identifier: str, results) -> str """) message += dedent(f""" - Read one with `read_note(identifier="", project="{project}")`, or search - further with `search_notes(query="{identifier}", project="{project}")`. + Read one with `bm_read(identifier="", project="{project}")`, or search + further with `bm_search(query="{identifier}", project="{project}")`. """) return message diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index eef12d556..0d0c0ad18 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -313,7 +313,7 @@ def _format_search_error_response( ## Alternative now - Run FTS search instead: - `search_notes(query="{query}", project="{project}", search_type="text")` + `bm_search(query="{query}", project="{project}", mode="text")` """).strip() if "pip install" in error_message.lower() and "semantic" in error_message.lower(): @@ -326,7 +326,7 @@ def _format_search_error_response( 1. Install/update Basic Memory: `pip install -U basic-memory` 2. Restart Basic Memory 3. Retry your query: - `search_notes(query="{query}", project="{project}", search_type="{search_type}")` + `bm_search(query="{query}", project="{project}", mode="{search_type}")` """).strip() # Corrupt/missing FastEmbed model cache (interrupted download leaves a partial @@ -363,11 +363,11 @@ def _format_search_error_response( 1. Delete the FastEmbed model cache so it re-downloads on the next search: `{cache_dir}` 2. Run your search again (the model downloads automatically on first use): - `search_notes(query="{query}", project="{project}", search_type="{search_type}")` + `bm_search(query="{query}", project="{project}", mode="{search_type}")` ## Workaround right now - Use full-text search, which needs no embedding model: - `search_notes(query="{query}", project="{project}", search_type="text")` + `bm_search(query="{query}", project="{project}", mode="text")` """).strip() # FTS5 syntax errors @@ -405,13 +405,13 @@ def _format_search_error_response( ## Try again with: ``` - search_notes(query="{clean_query}", project="{project}") + bm_search(query="{clean_query}", project="{project}") ``` ## Alternative search strategies: - - Break into simpler terms: `search_notes(query="{" ".join(clean_query.split()[:2])}", project="{project}")` - - Try different search types: `search_notes(query="{clean_query}", project="{project}", search_type="title")` - - Use filtering: `search_notes(query="{clean_query}", project="{project}", note_types=["note"])` + - Break into simpler terms: `bm_search(query="{" ".join(clean_query.split()[:2])}", project="{project}")` + - Try different search types: `bm_search(query="{clean_query}", project="{project}", mode="title")` + - Use filtering: `bm_search(query="{clean_query}", project="{project}", note_types=["note"])` """).strip() # Project not found errors (check before general "not found") @@ -422,11 +422,11 @@ def _format_search_error_response( The current project is not accessible or doesn't exist: {error_message} ## How to resolve: - 1. **Check available projects**: `list_memory_projects()` + 1. **Check available projects**: `bm_list(path="/")` 3. **Verify project setup**: Ensure your project is properly configured ## Current session info: - - See available projects: `list_memory_projects()` + - See available projects: `bm_list(path="/")` """).strip() # No results found @@ -453,28 +453,28 @@ def _format_search_error_response( - Try synonyms or related terms 3. **Use different search approaches**: - - **Text search**: `search_notes(query="{query}", project="{project}", search_type="text")` (searches full content) - - **Title search**: `search_notes(query="{query}", project="{project}", search_type="title")` (searches only titles) - - **Permalink search**: `search_notes(query="{query}", project="{project}", search_type="permalink")` (searches file paths) + - **Text search**: `bm_search(query="{query}", project="{project}", mode="text")` (searches full content) + - **Title search**: `bm_search(query="{query}", project="{project}", mode="title")` (searches only titles) + - **Permalink search**: `bm_search(query="{query}", project="{project}", mode="permalink")` (searches file paths) 4. **Try boolean operators for broader results**: - - OR search: `search_notes(query="{" OR ".join(query.split()[:3])}", project="{project}")` + - OR search: `bm_search(query="{" OR ".join(query.split()[:3])}", project="{project}")` - Remove restrictive terms: Focus on the most important keywords 5. **Use filtering to narrow scope**: - - By note type in frontmatter: `search_notes(query="{query}", project="{project}", note_types=["note"])` - - By recent content: `search_notes(query="{query}", project="{project}", after_date="1 week")` - - By entity type: `search_notes(query="{query}", project="{project}", entity_types=["observation"])` + - By note type in frontmatter: `bm_search(query="{query}", project="{project}", note_types=["note"])` + - By recent content: `bm_search(query="{query}", project="{project}", since="1 week")` + - By entity type: `bm_search(query="{query}", project="{project}", items=["observation"])` 6. **Try advanced search patterns**: - - Tag search: `search_notes(query="tag:your-tag", project="{project}")` - - Observation category: `search_notes(query="{query}", project="{project}", entity_types=["observation"], categories=["requirement"])` - - Pattern matching: `search_notes(query="*{query}*", project="{project}", search_type="permalink")` + - Tag search: `bm_search(query="tag:your-tag", project="{project}")` + - Observation category: `bm_search(query="{query}", project="{project}", items=["observation"], categories=["requirement"])` + - Pattern matching: `bm_search(query="*{query}*", project="{project}", mode="permalink")` ## Explore what content exists: - - **Recent activity**: `recent_activity(timeframe="7d")` - See what's been updated recently - - **List directories**: `list_directory(dir_name="/", project="{project}")` - Browse all content - - **Browse by folder**: `list_directory(dir_name="/notes", project="{project}")` or `list_directory("/docs")` + - **Recent activity**: `bm_search(since="7d", project="{project}")` - See what's been updated recently + - **List directories**: `bm_list(path="/", project="{project}")` - Browse all content + - **Browse by folder**: `bm_list(path="/notes", project="{project}")` or `bm_list("/docs")` """).strip() # Server/API errors @@ -490,9 +490,9 @@ def _format_search_error_response( 3. **Check project status**: Ensure your project is properly synced ## Alternative approaches: - - Browse files directly: `list_directory(dir_name="/", project="{project}")` - - Check recent activity: `recent_activity(timeframe="7d")` - - Try a different search type: `search_notes(query="{query}", project="{project}", search_type="title")` + - Browse files directly: `bm_list(path="/", project="{project}")` + - Check recent activity: `bm_search(since="7d", project="{project}")` + - Try a different search type: `bm_search(query="{query}", project="{project}", mode="title")` ## If the problem persists: The search index might need to be rebuilt. Send a message to support@basicmachines.co or check the project sync status. @@ -514,7 +514,7 @@ def _format_search_error_response( 3. **Check authentication**: You might need to re-authenticate ## Alternative actions: -- List available projects: `list_memory_projects()`""" +- List available projects: `bm_list(path="/")`""" # Generic fallback return f"""# Search Failed @@ -525,20 +525,20 @@ def _format_search_error_response( 1. **Simplify your query**: Try basic words without special characters 2. **Check search syntax**: Ensure boolean operators are correctly formatted 3. **Verify project access**: Make sure you can access the current project -4. **Test with simple search**: Try `search_notes("test")` to verify search is working +4. **Test with simple search**: Try `bm_search("test")` to verify search is working ## Alternative search approaches: - **Different search types**: - - Title only: `search_notes(query="{query}", project="{project}", search_type="title")` - - Permalink patterns: `search_notes(query="{query}*", project="{project}", search_type="permalink")` -- **With filters**: `search_notes(query="{query}", project="{project}", note_types=["note"])` -- **Recent content**: `search_notes(query="{query}", project="{project}", after_date="1 week")` -- **Boolean variations**: `search_notes(query="{" OR ".join(query.split()[:2])}", project="{project}")` + - Title only: `bm_search(query="{query}", project="{project}", mode="title")` + - Permalink patterns: `bm_search(query="{query}*", project="{project}", mode="permalink")` +- **With filters**: `bm_search(query="{query}", project="{project}", note_types=["note"])` +- **Recent content**: `bm_search(query="{query}", project="{project}", since="1 week")` +- **Boolean variations**: `bm_search(query="{" OR ".join(query.split()[:2])}", project="{project}")` ## Explore your content: -- **Browse files**: `list_directory(dir_name="/", project="{project}")` - See all available content -- **Recent activity**: `recent_activity(timeframe="7d")` - Check what's been updated -- **All projects**: `list_memory_projects()` +- **Browse files**: `bm_list(path="/", project="{project}")` - See all available content +- **Recent activity**: `bm_search(since="7d", project="{project}")` - Check what's been updated +- **All projects**: `bm_list(path="/")` ## Search syntax reference: - **Basic**: `keyword` or `multiple words` @@ -546,7 +546,7 @@ def _format_search_error_response( - **Phrases**: `"exact phrase"` - **Grouping**: `(term1 OR term2) AND term3` - **Tags**: `tag:example` -- **Observation categories**: `entity_types=["observation"], categories=["requirement"]`""" +- **Observation categories**: `items=["observation"], categories=["requirement"]`""" def _format_search_markdown( @@ -572,18 +572,12 @@ def _format_search_markdown( # project it resolves to that one project. So for an all-projects miss, point at the # enumerator instead — suggesting recent_activity() could silently narrow to the # default project and miss activity elsewhere. - suggestion = "call list_memory_projects() to see what exists across your projects" + suggestion = 'call bm_list(path="/") to see what exists across your projects' elif project_id: # Names collide across cloud workspaces; route the orientation call by external id. - suggestion = ( - f'call recent_activity(project_id="{project_id}") to orient — if the project is ' - "empty it will guide creating a first note" - ) + suggestion = f'call bm_list(path="/", project="{project_id}") to browse the project' else: - suggestion = ( - f'call recent_activity(project="{project}") to orient — if the project is empty ' - "it will guide creating a first note" - ) + suggestion = f'call bm_list(path="/", project="{project}") to browse the project' return ( f"No results found for '{query or ''}' in project '{project}'. " f"Try broader or different terms, or {suggestion}." @@ -1112,7 +1106,7 @@ async def _search_all_projects( "`after_date` filters by when content was last updated. `valid_at` / " "`valid_overlaps` / `time_kind` filter by dates written inside notes (e.g. " "`@effective[2026-06-10,2026-07-27)`) and exclude undated content. Returns ranked, " - "paginated results; read full content with read_note or cat. Syntax reference: " + "paginated results; read full content with bm_read or cat. Syntax reference: " "memory://man/search-notes(3)." ), tags={"search"}, diff --git a/src/basic_memory/mcp/tools/write_note.py b/src/basic_memory/mcp/tools/write_note.py index 167e386e7..277b67a54 100644 --- a/src/basic_memory/mcp/tools/write_note.py +++ b/src/basic_memory/mcp/tools/write_note.py @@ -90,7 +90,7 @@ def _compose_workspace_project_route( description=( "Create a markdown note. If the note already exists, returns an error by default " "— pass overwrite=True to replace. directory is required. For incremental changes " - "to an existing note use edit_note. A new note's result may list similar existing " + "to an existing note use bm_edit. A new note's result may list similar existing " "notes; that is advisory and does not block the write. Pass expected_checksum with " "overwrite=True to replace the note only if it is still the revision you read." ), @@ -126,7 +126,7 @@ async def write_note( Creates a markdown note with semantic observations and relations. If the note already exists, returns an error by default. Pass overwrite=True - to replace the existing note. For incremental updates, use edit_note instead. + to replace the existing note. For incremental updates, use bm_edit instead. Project Resolution: Server resolves projects using a unified priority chain (same in local and cloud modes): @@ -200,9 +200,9 @@ async def write_note( keys this write sets (title, type, tags, metadata, or frontmatter in content) replace the old values. Existing frontmatter that cannot be parsed is discarded rather than merged. To remove a key, rewrite it - with edit_note or edit the file. + with bm_edit or edit the file. expected_checksum: Optional revision precondition for overwrite=True: the checksum - of the note you read (from a JSON read_note, write_note or edit_note + of the note you read (from a JSON bm_read, bm_write or bm_edit result). The note is replaced only while it is still that revision; otherwise the tool reports a revision conflict with the current checksum and changes @@ -574,9 +574,9 @@ async def write_note( format_similar_notes_section( similar_notes, same_topic=lambda closest_ref: ( - f'`read_note("{closest_ref}")`, then ' - f'`edit_note("{closest_ref}", operation="append", content="...")` ' - f'and `delete_note("{response_permalink}")`' + f'`bm_read("{closest_ref}")`, then ' + f'`bm_edit("{closest_ref}", command="append", content="...")` ' + f'and `bm_delete("{response_permalink}")`' ), ) ) @@ -646,14 +646,14 @@ def _format_overwrite_error(title: str, identifier: str, project_name: str) -> s **"{title}"** already exists (`{identifier}`). - `write_note` does not overwrite by default. Choose an option: + `bm_write` does not overwrite by default. Choose an option: | Goal | Action | |------|--------| - | Append content | `edit_note("{identifier}", operation="append", content="...")` | - | Prepend content | `edit_note("{identifier}", operation="prepend", content="...")` | - | Replace a section | `edit_note("{identifier}", operation="replace_section", section="...", content="...")` | - | Full replace | `write_note(title="{title}", content="...", directory="...", overwrite=True)` | - | Inspect first | `read_note("{identifier}")` | + | Append content | `bm_edit("{identifier}", command="append", content="...")` | + | Prepend content | `bm_edit("{identifier}", command="prepend", content="...")` | + | Replace a section | `bm_edit("{identifier}", command="replace_section", section="...", content="...")` | + | Full replace | `bm_write(identifier="{identifier}", content="...", overwrite=True)` | + | Inspect first | `bm_read("{identifier}")` | Project: {project_name}""") diff --git a/src/basic_memory/templates/prompts/continue_conversation.hbs b/src/basic_memory/templates/prompts/continue_conversation.hbs index 89d52ec1f..8a51af619 100644 --- a/src/basic_memory/templates/prompts/continue_conversation.hbs +++ b/src/basic_memory/templates/prompts/continue_conversation.hbs @@ -2,7 +2,7 @@ This is a memory retrieval session. -The notes below came from Basic Memory. Open a note with `read_note` or its linked notes with `build_context` when a snippet is not enough to respond. +The notes below came from Basic Memory. Open a note with `bm_read` or its linked notes with `bm_context` when a snippet is not enough to respond. Here's what I found from previous conversations: @@ -31,7 +31,7 @@ Here's what I found from previous conversations: {{/each}} {{/if}} -You can read this document with: `read_note("{{ primary_result.permalink }}")` +You can read this document with: `bm_read("{{ primary_result.permalink }}")` {{#if related_results}} ## Related Context @@ -42,9 +42,9 @@ You can read this document with: `read_note("{{ primary_result.permalink }}")` - title: {{ title }} {{#if permalink}} -You can view this document with: `read_note("{{ permalink }}")` +You can view this document with: `bm_read("{{ permalink }}")` {{else}} -You can view this file with: `read_content("{{ file_path }}")` +You can view this file with: `bm_read("{{ file_path }}")` {{/if}} {{/each}} @@ -55,13 +55,13 @@ You can view this file with: `read_content("{{ file_path }}")` {{else}} The supplied query did not return any information specifically on this topic. -Try a different search term or check `recent_activity`. If the user wants this topic tracked, offer to start a note with `write_note`. +Try a different search term or check `bm_search(since="7d")`. If the user wants this topic tracked, offer to start a note with `bm_write`. {{/if}} ## Next Steps You can: -- Explore more with: `search_notes("{{ topic }}")` -- See what's changed: `recent_activity(timeframe="{{default timeframe "7d"}}")` +- Explore more with: `bm_search("{{ topic }}")` +- See what's changed: `bm_search(since="{{default timeframe "7d"}}")` -If this conversation produces a decision or insight worth keeping, offer to save it with `write_note`. +If this conversation produces a decision or insight worth keeping, offer to save it with `bm_write`. diff --git a/src/basic_memory/templates/prompts/search.hbs b/src/basic_memory/templates/prompts/search.hbs index 2d0aeadf3..f65fcff07 100644 --- a/src/basic_memory/templates/prompts/search.hbs +++ b/src/basic_memory/templates/prompts/search.hbs @@ -22,10 +22,10 @@ Here are the most relevant results: {{/if}} {{#if permalink}} - You can view this content with: `read_note("{{ permalink }}")` - Or explore its context with: `build_context("memory://{{ permalink }}")` + You can view this content with: `bm_read("{{ permalink }}")` + Or explore its context with: `bm_context("memory://{{ permalink }}")` {{else}} - You can view this file with: `read_content("{{ file_path }}")` + You can view this file with: `bm_read("{{ file_path }}")` {{/if}} {{/dedent}} {{/if_cond}} @@ -34,19 +34,19 @@ Here are the most relevant results: ## Next Steps You can: -- Refine your search: `search_notes("{{ query }} AND additional_term")` -- Exclude terms: `search_notes("{{ query }} NOT exclude_term")` -- View more results: `search_notes("{{ query }}", after_date=None)` -- Check recent activity: `recent_activity()` +- Refine your search: `bm_search("{{ query }} AND additional_term")` +- Exclude terms: `bm_search("{{ query }} NOT exclude_term")` +- View more results: `bm_search("{{ query }}", since=None)` +- Check recent activity: `bm_search(since="7d")` -If these results add up to something worth keeping, offer to save a synthesis with `write_note`. +If these results add up to something worth keeping, offer to save a synthesis with `bm_write`. {{else}} I couldn't find any results for this query. ## Other Suggestions - Try a different search term - Broaden your search criteria -- Check recent activity with `recent_activity(timeframe="1w")` +- Check recent activity with `bm_search(since="1w")` -If the user wants this topic tracked, offer to start a note with `write_note`. +If the user wants this topic tracked, offer to start a note with `bm_write`. {{/if}} diff --git a/tests/api/v2/test_knowledge_router.py b/tests/api/v2/test_knowledge_router.py index b0ebcd73c..260f3b16e 100644 --- a/tests/api/v2/test_knowledge_router.py +++ b/tests/api/v2/test_knowledge_router.py @@ -567,7 +567,7 @@ async def test_create_entity_conflict_returns_409(client: AsyncClient, v2_projec params={"fast": False}, ) assert response.status_code == 409 - expected_detail = "Note already exists. Use edit_note to modify it, or delete it first." + expected_detail = "Note already exists. Use bm_edit to modify it, or delete it first." assert response.json()["detail"] == expected_detail diff --git a/tests/api/v2/test_prompt_router.py b/tests/api/v2/test_prompt_router.py index aaa20b2bc..9ea223928 100644 --- a/tests/api/v2/test_prompt_router.py +++ b/tests/api/v2/test_prompt_router.py @@ -141,7 +141,7 @@ async def test_search_prompt_no_results( prompt = result["prompt"] assert 'Search Results for: "NonExistentQuery12345"' in prompt assert "I couldn't find any results for this query" in prompt - assert "offer to start a note with `write_note`" in prompt + assert "offer to start a note with `bm_write`" in prompt assert "Opportunity to Capture Knowledge" not in prompt diff --git a/tests/mcp/test_tool_delete_note.py b/tests/mcp/test_tool_delete_note.py index a379fa34e..6ff70d220 100644 --- a/tests/mcp/test_tool_delete_note.py +++ b/tests/mcp/test_tool_delete_note.py @@ -32,8 +32,8 @@ def test_format_delete_error_note_not_found(self, test_project): assert "# Delete Failed - Note Not Found" in result assert "The note 'test-note' could not be found" in result - assert 'search_notes(query="test-note", project="test-project")' in result - assert 'delete_note(identifier="...", project="test-project")' in result + assert 'bm_search(query="test-note", project="test-project")' in result + assert 'bm_delete(identifier="...", project="test-project")' in result assert "already be deleted" in result def test_format_delete_error_permission_denied(self, test_project): @@ -43,7 +43,7 @@ def test_format_delete_error_permission_denied(self, test_project): assert "# Delete Failed - Permission Error" in result assert "No write access to delete 'test-note'" in result assert "locked by another application" in result - assert "list_memory_projects()" in result + assert 'bm_list(path="/")' in result def test_format_delete_error_access_forbidden(self, test_project): """Test formatting for access forbidden errors.""" @@ -60,7 +60,7 @@ def test_format_delete_error_server_error(self, test_project): assert "# Delete Failed - System Error" in result assert "A system error occurred while deleting 'test-note'" in result - assert 'read_note(identifier="test-note", project="test-project")' in result + assert 'bm_read(identifier="test-note", project="test-project")' in result assert "support@basicmemory.com" in result def test_format_delete_error_filesystem_error(self, test_project): @@ -99,7 +99,7 @@ def test_format_delete_error_generic(self, test_project): assert "# Delete Failed" in result assert "Error deleting note 'test-note' in test-project: unknown error" in result - assert 'search_notes(query="test-note", project="test-project")' in result + assert 'bm_search(query="test-note", project="test-project")' in result def test_format_delete_error_with_complex_identifier(self, test_project): """Test formatting with complex identifiers (permalinks).""" @@ -107,7 +107,7 @@ def test_format_delete_error_with_complex_identifier(self, test_project): test_project.name, "entity not found", "folder/note-title" ) - assert 'search_notes(query="note-title", project="test-project")' in result + assert 'bm_search(query="note-title", project="test-project")' in result assert "Note Title" in result # Title format assert "folder/note-title" in result # Permalink format diff --git a/tests/mcp/test_tool_edit_note.py b/tests/mcp/test_tool_edit_note.py index 0f60d35d5..cfa1a3da4 100644 --- a/tests/mcp/test_tool_edit_note.py +++ b/tests/mcp/test_tool_edit_note.py @@ -286,8 +286,8 @@ async def test_edit_note_nonexistent_note_find_replace(client, test_project): result = str(exc_info.value) assert "# Edit Failed" in result - assert "search_notes" in result # Should suggest searching - assert "append" in result # Should suggest using append/prepend instead + assert "bm_search" in result # Should suggest searching + assert "bm_write" in result # Public bm_edit does not create missing notes @pytest.mark.asyncio @@ -305,7 +305,7 @@ async def test_edit_note_nonexistent_note_replace_section(client, test_project): result = str(exc_info.value) assert "# Edit Failed" in result - assert "search_notes" in result # Should suggest searching + assert "bm_search" in result # Should suggest searching @pytest.mark.asyncio @@ -519,7 +519,7 @@ async def fail_patch(*args, **kwargs): # pragma: no cover result = str(refusal.value) assert "# Edit Failed - Note Not Found In This Project" in result assert f"selected project `{test_project.name}`" in result - assert f'project_id="{target_project_id}"' in result + assert f'project="{target_project_id}"' in result assert target_entity_id not in result @@ -728,7 +728,7 @@ async def test_edit_note_find_replace_no_matches(client, test_project): result = str(exc_info.value) assert "# Edit Failed - Text Not Found" in result - assert "read_note" in result # Should suggest reading the note first + assert "bm_read" in result # Should suggest reading the note first assert "Alternative approaches" in result # Should suggest alternatives @@ -809,7 +809,7 @@ async def test_edit_note_replace_section_multiple_sections(client, test_project) result = str(exc_info.value) assert "# Edit Failed - Duplicate Section Headers" in result assert "Multiple sections found" in result - assert "read_note" in result # Should suggest reading the note first + assert "bm_read" in result # Should suggest reading the note first assert "Make headers unique" in result # Should suggest making headers unique @@ -2021,7 +2021,7 @@ async def dropped_connection(self, entity_id, patch_data, *, base_checksum=None) assert payload["error"] == "EDIT_OUTCOME_UNKNOWN" assert "connection dropped" in payload["detail"] # The suggested check names the routed project by id, not a reusable name. - assert f'project_id="{test_project.external_id}"' in payload["message"] + assert f'project="{test_project.external_id}"' in payload["message"] @pytest.mark.asyncio diff --git a/tests/mcp/test_tool_guidance.py b/tests/mcp/test_tool_guidance.py new file mode 100644 index 000000000..a08f9b385 --- /dev/null +++ b/tests/mcp/test_tool_guidance.py @@ -0,0 +1,69 @@ +"""Recovery guidance must work for agents that have only the public bm_* tools.""" + +import pytest +from fastmcp.exceptions import ToolError + +from basic_memory.mcp.tools import bm_delete, bm_read, bm_write, edit_note +from basic_memory.mcp.tools.delete_note import _format_delete_error_response +from basic_memory.mcp.tools.edit_note import _unknown_outcome_guidance + + +@pytest.mark.asyncio +@pytest.mark.parametrize("output_format", ["text", "json"]) +async def test_refused_edit_points_to_bm_read(client, test_project, output_format): + await bm_write( + identifier="notes/guarded.md", content="Original content", project=test_project.name + ) + + with pytest.raises(ToolError) as refused: + await edit_note( + identifier="notes/guarded.md", + operation="append", + content="Must not land", + expected_checksum="stale", + output_format=output_format, + project=test_project.name, + ) + + guidance = str(refused.value) + # JSON output escapes quotes, but the suggested tool and parameter stay the same. + assert "bm_read(identifier=" in guidance + assert "read_note(" not in guidance + assert "project_id=" not in guidance + assert test_project.external_id in guidance + content = await bm_read("notes/guarded.md", project=test_project.external_id) + assert isinstance(content, str) + assert "Original content" in content + assert "Must not land" not in content + + +@pytest.mark.asyncio +@pytest.mark.parametrize("output_format", ["text", "json"]) +async def test_refused_delete_points_to_bm_read(client, test_project, output_format): + await bm_write( + identifier="notes/guarded.md", content="Keep this note", project=test_project.name + ) + with pytest.raises(ToolError) as refused: + await bm_delete( + "notes/guarded.md", + expected_checksum="stale", + output_format=output_format, + project=test_project.name, + ) + assert "bm_read(identifier=" in str(refused.value) + assert "read_note(" not in str(refused.value) + assert "Keep this note" in str(await bm_read("notes/guarded.md", project=test_project.name)) + + +@pytest.mark.parametrize("error", ["filesystem error", "database error", "server error"]) +def test_legacy_delete_failure_points_to_bm_read(error: str) -> None: + guidance = _format_delete_error_response("work", error, "notes/guarded.md") + assert 'bm_read(identifier="notes/guarded.md", project="work")' in guidance + assert "read_note(" not in guidance + + +def test_unknown_edit_outcome_points_to_bm_read_by_project_id() -> None: + guidance = _unknown_outcome_guidance("notes/guarded.md", "project-uuid") + assert 'bm_read(identifier="notes/guarded.md", project="project-uuid")' in guidance + assert "read_note(" not in guidance + assert "project_id=" not in guidance diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index b604b906f..67738a2fa 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -562,7 +562,7 @@ def test_format_search_error_semantic_unavailable(self): assert "# Search Failed - Semantic Search Unavailable" in result assert "startup log" in result - assert 'search_type="text"' in result + assert 'mode="text"' in result def test_format_search_error_semantic_dependencies_missing(self): """Test formatting for missing semantic dependencies.""" @@ -595,7 +595,7 @@ def test_format_search_error_corrupt_embedding_model(self): # Names the actual resolved cache dir so the user knows what to delete. assert expected_cache_dir in result # Offers full-text search as an immediate workaround. - assert 'search_type="text"' in result + assert 'mode="text"' in result def test_format_search_error_load_model_phrase_does_not_overmatch(self): """A generic error mentioning 'load model' (no 'from') must not hit the embedding branch. diff --git a/tests/mcp/test_tool_write_note.py b/tests/mcp/test_tool_write_note.py index 32f13794c..cafb4dce6 100644 --- a/tests/mcp/test_tool_write_note.py +++ b/tests/mcp/test_tool_write_note.py @@ -1442,7 +1442,7 @@ async def test_write_note_blocks_overwrite_by_default(self, app, test_project): result2 = str(exc_info.value) assert "# Error: Note already exists" in result2 assert "Guard Test" in result2 - assert "edit_note" in result2 + assert "bm_edit" in result2 # Original content should be untouched content = await read_note("guard/guard-test", project=test_project.name) @@ -1598,7 +1598,7 @@ async def test_write_note_conflict_after_move_names_the_note_at_the_path( await write_note( project=test_project.name, title="Mover", directory="dirA", content="refused" ) - assert f'edit_note("{replacement["permalink"]}"' in str(text_refusal.value) + assert f'bm_edit("{replacement["permalink"]}"' in str(text_refusal.value) @pytest.mark.asyncio async def test_write_note_conflict_qualifies_the_permalink_in_a_workspace( @@ -1900,8 +1900,8 @@ async def test_write_note_surfaces_similar_existing_notes(app, test_project, stu assert f"- Reference BU Product Mapping (`{reference_permalink}`)" in section assert "- Product Catalog (`" in section assert "- BU Mapping Analysis (`" not in section - assert f'read_note("{reference_permalink}")' in section - assert f'delete_note("{new_permalink}")' in section + assert f'bm_read("{reference_permalink}")' in section + assert f'bm_delete("{new_permalink}")' in section assert "relates_to [[Reference BU Product Mapping]]" in section assert "score" not in section.lower() diff --git a/tests/test_bm_tool_manual_parameters.py b/tests/test_bm_tool_manual_parameters.py new file mode 100644 index 000000000..c8567f61e --- /dev/null +++ b/tests/test_bm_tool_manual_parameters.py @@ -0,0 +1,26 @@ +"""Every public bm_* parameter must appear in its bundled manual.""" + +import ast +import inspect +import re + +import pytest + +from basic_memory.man import PageRef, extract_mcp_synopsis, extract_parameters, find_page +from basic_memory.mcp import tools + + +@pytest.mark.parametrize("name", [name for name in tools.__all__ if name.startswith("bm_")]) +def test_bm_manual_lists_every_tool_parameter(name: str) -> None: + # Inspect the exported function rather than the visible registry: optional tools + # such as project deletion must stay documented even when hidden at startup. + parameters = set(inspect.signature(getattr(tools, name)).parameters) - {"context"} + page = find_page(PageRef(name.replace("_", "-"), 3)) + assert page is not None, f"{name} has no manual page" + text = page.read() + documented = set(re.findall(r"^- \*\*(\w+)\*\*", extract_parameters(text), re.MULTILINE)) + synopsis = ast.parse(f"def {extract_mcp_synopsis(text)}: pass").body[0] + assert isinstance(synopsis, ast.FunctionDef) + listed = {arg.arg for arg in synopsis.args.args + synopsis.args.kwonlyargs} + assert documented == parameters, f"{name} PARAMETERS drifted; run `just man-regen`" + assert listed == parameters, f"{name} SYNOPSIS drifted; run `just man-regen`"