Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<project>/' 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")
Expand Down Expand Up @@ -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,
)
Comment on lines +901 to +905

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize project prefixes before disk recovery

When permalinks_include_project is true and notes/foo.md exists on disk but is not indexed yet, an append via test-project/notes/foo reaches _resolve_after_disk_recovery with the still-prefixed entity_identifier, so index-file probes only test-project/notes/foo(.md/.markdown) and misses the real file. These lines then strip the prefix and POST a create at notes/foo.md; local creation rejects the unexpected existing file (while deferred/cloud creation can accept content that cannot materialize), so the documented recovery path does not append the note. Derive and share the project-relative identifier before attempting disk recovery.

AGENTS.md reference: AGENTS.md:L157-L161

Useful? React with 👍 / 👎.

title, directory = _parse_identifier_to_title_and_directory(
create_identifier
)

# Validate directory path (same security check as write_note)
project_path = active_project.home
Expand Down
109 changes: 109 additions & 0 deletions tests/mcp/test_tool_edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
Loading