diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py index 0112a7f02..422c3cc86 100644 --- a/src/basic_memory/mcp/tools/edit_note.py +++ b/src/basic_memory/mcp/tools/edit_note.py @@ -78,6 +78,33 @@ def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str] return title, directory +def _strip_active_project_prefix( + identifier: str, + project_permalink: str, + *, + include_project: bool, +) -> str: + """Drop a leading active-project segment before deriving a file path. + + Trigger: append/prepend auto-create receives a permalink-shaped identifier + such as 'example/notes/note' or 'memory://example/notes/note'. + Why: permalinks carry the project slug (permalinks_include_project) and + memory:// URLs route by it, but the slug is not part of the on-disk path; + keeping it would create a '/' folder. + Outcome: the remainder is used as directory/title. Plain identifiers are + only stripped when generated permalinks include the project slug, so a + real folder named like the project stays reachable as 'example/example/...'. + """ + is_memory_url = identifier.startswith("memory://") + cleaned = identifier[len("memory://") :] if is_memory_url else identifier + if not (is_memory_url or include_project): + return identifier + prefix = f"{project_permalink}/" + if cleaned.startswith(prefix): + return cleaned[len(prefix) :] + return identifier + + # Suffixes mimetypes maps to text/markdown (extension matching is case-insensitive), # mirroring FileService.is_markdown which gates the index-file endpoint server-side. _MARKDOWN_SUFFIXES = (".md", ".markdown") @@ -871,7 +898,14 @@ async def edit_note( # content, while find_replace/replace_section require existing # content to modify # Outcome: note is created via the same path as write_note - title, directory = _parse_identifier_to_title_and_directory(identifier) + create_identifier = _strip_active_project_prefix( + identifier.strip(), + active_project.permalink, + include_project=ConfigManager().config.permalinks_include_project, + ) + title, directory = _parse_identifier_to_title_and_directory( + create_identifier + ) # Validate directory path (same security check as write_note) project_path = active_project.home diff --git a/tests/mcp/test_tool_edit_note.py b/tests/mcp/test_tool_edit_note.py index 9184476b8..c560e3c91 100644 --- a/tests/mcp/test_tool_edit_note.py +++ b/tests/mcp/test_tool_edit_note.py @@ -10,8 +10,10 @@ from basic_memory.mcp.clients import KnowledgeClient from basic_memory.mcp.tools.edit_note import ( + _parse_identifier_to_title_and_directory, _resolve_after_disk_recovery, _revision_conflict_detail, + _strip_active_project_prefix, edit_note, ) from basic_memory.mcp.tools.read_note import read_note @@ -81,6 +83,73 @@ def test_edit_note_workspace_project_route_helper_rejects_invalid_inputs( edit_note_module._compose_workspace_project_route(**route_kwargs) +@pytest.mark.parametrize( + ("identifier", "project_permalink", "include_project", "expected"), + [ + ( + "test-project/scratch/repro/note-c", + "test-project", + True, + "scratch/repro/note-c", + ), + ( + "memory://test-project/scratch/repro/note-c", + "test-project", + True, + "scratch/repro/note-c", + ), + ( + "memory://test-project/scratch/repro/note-c", + "test-project", + False, + "scratch/repro/note-c", + ), + ( + # Plain path keeps a real folder named like the project when + # permalinks do not include the project slug. + "test-project/scratch/repro/note-c", + "test-project", + False, + "test-project/scratch/repro/note-c", + ), + ( + "scratch/repro/note-a", + "test-project", + True, + "scratch/repro/note-a", + ), + ( + "other-project/scratch/note", + "test-project", + True, + "other-project/scratch/note", + ), + ], +) +def test_strip_active_project_prefix(identifier, project_permalink, include_project, expected): + """Project-qualified create identifiers drop only the active project slug.""" + assert ( + _strip_active_project_prefix( + identifier, + project_permalink, + include_project=include_project, + ) + == expected + ) + + +def test_strip_active_project_prefix_feeds_create_parser(): + """Stripped permalinks parse into the project-relative directory/title.""" + create_identifier = _strip_active_project_prefix( + "test-project/scratch/repro/note-c", + "test-project", + include_project=True, + ) + title, directory = _parse_identifier_to_title_and_directory(create_identifier) + assert title == "note-c" + assert directory == "scratch/repro" + + @pytest.mark.asyncio async def test_edit_note_append_operation(client, test_project): """Test appending content to an existing note.""" @@ -388,6 +457,46 @@ async def test_edit_note_append_creates_json_format(client, test_project): assert result["operation"] == "append" +@pytest.mark.asyncio +async def test_edit_note_append_strips_project_prefix_on_autocreate(client, test_project): + """Project-prefixed identifiers must not create a nested project directory.""" + identifier = f"{test_project.permalink}/scratch/repro/note-c" + result = await edit_note( + project=test_project.name, + identifier=identifier, + operation="append", + content="Disposable reproduction note.", + output_format="json", + ) + + assert isinstance(result, dict) + assert result["fileCreated"] is True + assert result["file_path"] == "scratch/repro/note-c.md" + assert (Path(test_project.path) / "scratch" / "repro" / "note-c.md").exists() + assert not (Path(test_project.path) / test_project.permalink / "scratch").exists() + + +@pytest.mark.asyncio +async def test_edit_note_append_strips_project_prefix_memory_url_on_autocreate( + client, test_project +): + """memory:// URLs that include the active project create at the relative path.""" + identifier = f"memory://{test_project.permalink}/scratch/repro/note-mem" + result = await edit_note( + project=test_project.name, + identifier=identifier, + operation="append", + content="Disposable memory-url reproduction note.", + output_format="json", + ) + + assert isinstance(result, dict) + assert result["fileCreated"] is True + assert result["file_path"] == "scratch/repro/note-mem.md" + assert (Path(test_project.path) / "scratch" / "repro" / "note-mem.md").exists() + assert not (Path(test_project.path) / test_project.permalink / "scratch").exists() + + @pytest.mark.asyncio async def test_edit_note_memory_url_unresolved_project_never_autocreates(client, test_project): """A failed memory URL route must not create a phantom note in the active project."""