Skip to content
Merged
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
22 changes: 18 additions & 4 deletions src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def _format_error_response(


@mcp.tool(
description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
description="Edit an existing markdown note using various operations like append, prepend, find_replace, replace_section, insert_before_section, or insert_after_section.",
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def edit_note(
Expand Down Expand Up @@ -190,6 +190,8 @@ async def edit_note(
- "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 content under a specific markdown header (note must exist)
- "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: The content to add or use for replacement
project: Project name to edit in. Optional - server will resolve using hierarchy.
If unknown, use list_memory_projects() to discover available projects.
Expand Down Expand Up @@ -257,7 +259,14 @@ async def edit_note(
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)

# Validate operation
valid_operations = ["append", "prepend", "find_replace", "replace_section"]
valid_operations = [
"append",
"prepend",
"find_replace",
"replace_section",
"insert_before_section",
"insert_after_section",
]
if operation not in valid_operations:
raise ValueError(
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
Expand All @@ -266,8 +275,9 @@ async def edit_note(
# Validate required parameters for specific operations
if operation == "find_replace" and not find_text:
raise ValueError("find_text parameter is required for find_replace operation")
if operation == "replace_section" and not section:
raise ValueError("section parameter is required for replace_section operation")
section_ops = ("replace_section", "insert_before_section", "insert_after_section")
if operation in section_ops and not section:
raise ValueError("section parameter is required for section-based operations")

# Use the PATCH endpoint to edit the entity
try:
Expand Down Expand Up @@ -389,6 +399,10 @@ async def edit_note(
summary.append("operation: Find and replace operation completed")
elif operation == "replace_section":
summary.append(f"operation: Replaced content under section '{section}'")
elif operation == "insert_before_section":
summary.append(f"operation: Inserted content before section '{section}'")
elif operation == "insert_after_section":
summary.append(f"operation: Inserted content after section '{section}'")

# Count observations by category (reuse logic from write_note)
categories = {}
Expand Down
21 changes: 18 additions & 3 deletions src/basic_memory/schemas/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,14 @@ class EditEntityRequest(BaseModel):
Supports various operation types for different editing scenarios.
"""

operation: Literal["append", "prepend", "find_replace", "replace_section"]
operation: Literal[
"append",
"prepend",
"find_replace",
"replace_section",
"insert_before_section",
"insert_after_section",
]
content: str
section: Optional[str] = None
find_text: Optional[str] = None
Expand All @@ -75,8 +82,16 @@ class EditEntityRequest(BaseModel):
@classmethod
def validate_section_for_replace_section(cls, v, info):
"""Ensure section is provided for replace_section operation."""
if info.data.get("operation") == "replace_section" and not v:
raise ValueError("section parameter is required for replace_section operation")
if (
info.data.get("operation")
in (
"replace_section",
"insert_before_section",
"insert_after_section",
)
and not v
):
raise ValueError("section parameter is required for section-based operations")
return v

@field_validator("find_text")
Expand Down
75 changes: 75 additions & 0 deletions src/basic_memory/services/entity_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,14 @@ def apply_edit_operation(
raise ValueError("section cannot be empty or whitespace only")
return self.replace_section_content(current_content, section, content)

elif operation in ("insert_before_section", "insert_after_section"):
if not section:
raise ValueError("section is required for insert section operations")
if not section.strip():
raise ValueError("section cannot be empty or whitespace only")
position = "before" if operation == "insert_before_section" else "after"
return self.insert_relative_to_section(current_content, section, content, position)

else:
raise ValueError(f"Unsupported operation: {operation}")

Expand Down Expand Up @@ -979,6 +987,73 @@ def replace_section_content(

return "\n".join(result_lines)

def insert_relative_to_section(
self,
current_content: str,
section_header: str,
new_content: str,
position: str,
) -> str:
"""Insert content before or after a section heading without consuming it.

Unlike replace_section_content, this preserves the section heading and its
existing content. The new content is inserted immediately before or after
the heading line.

Args:
current_content: The current markdown content
section_header: The section header to anchor on (e.g., "## Section Name")
new_content: The content to insert
position: "before" to insert above the heading, "after" to insert below it

Returns:
The updated content with new_content inserted relative to the heading

Raises:
ValueError: If the section header is not found or appears more than once
"""
# Normalize the section header (ensure it starts with #)
if not section_header.startswith("#"):
section_header = "## " + section_header

lines = current_content.split("\n")
matching_indices = [
i for i, line in enumerate(lines) if line.strip() == section_header.strip()
]

if len(matching_indices) == 0:
raise ValueError(
f"Section '{section_header}' not found in document. "
f"Use replace_section to create a new section."
)
if len(matching_indices) > 1:
raise ValueError(
f"Multiple sections found with header '{section_header}'. "
f"Section insertion requires unique headers."
)

idx = matching_indices[0]

if position == "before":
# Insert new content before the section heading
before = lines[:idx]
after = lines[idx:]
# Ensure blank line separation
insert_lines = new_content.rstrip("\n").split("\n")
if before and before[-1].strip() != "":
insert_lines = [""] + insert_lines
return "\n".join(before + insert_lines + [""] + after)
else:
# Insert new content after the section heading line
before = lines[: idx + 1]
after = lines[idx + 1 :]
insert_lines = new_content.rstrip("\n").split("\n")
# Ensure blank line separation so inserted text doesn't merge
# with existing section content into a single paragraph
if after and after[0].strip() != "":
insert_lines = insert_lines + [""]
return "\n".join(before + insert_lines + after)
Comment on lines +1048 to +1055

Choose a reason for hiding this comment

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

P2 Badge Preserve paragraph boundaries after insert_after_section

When insert_after_section inserts plain text into a section that already starts with paragraph text, the current join logic places the old first line immediately after the inserted line with only a single newline, so Markdown treats them as one paragraph. This means the operation can unintentionally merge newly inserted text with existing section content instead of inserting a distinct block beneath the heading; adding explicit blank-line separation (as the before branch already does) avoids that formatting regression.

Useful? React with 👍 / 👎.


def _prepend_after_frontmatter(self, current_content: str, content: str) -> str:
"""Prepend content after frontmatter, preserving frontmatter structure."""

Expand Down
2 changes: 1 addition & 1 deletion test-int/cli/test_cli_tool_edit_note_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ def test_edit_note_replace_section_fails_without_section(
)

assert result.exit_code != 0
assert "section parameter is required for replace_section operation" in result.output
assert "section parameter is required for section-based operations" in result.output


def test_edit_note_append_creates_nonexistent_note_cli(
Expand Down
95 changes: 94 additions & 1 deletion tests/mcp/test_tool_edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ async def test_edit_note_replace_section_missing_section(client, test_project):
content="new content",
)

assert "section parameter is required for replace_section operation" in str(exc_info.value)
assert "section parameter is required for section-based operations" in str(exc_info.value)


@pytest.mark.asyncio
Expand Down Expand Up @@ -611,3 +611,96 @@ async def test_edit_note_preserves_permalink_when_frontmatter_missing(client, te
assert f"permalink: {test_project.name}/test/test-note" in second_result
assert f"[Session: Using project '{test_project.name}']" in second_result
# The edit should succeed without validation errors


@pytest.mark.asyncio
async def test_edit_note_insert_before_section_operation(client, test_project):
"""Test inserting content before a section heading."""
# Create initial note with sections
await write_note(
project=test_project.name,
title="Insert Before Doc",
directory="docs",
content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.",
)

result = await edit_note(
project=test_project.name,
identifier="docs/insert-before-doc",
operation="insert_before_section",
content="--- inserted divider ---",
section="## Details",
)

assert isinstance(result, str)
assert "Edited note (insert_before_section)" in result
assert f"project: {test_project.name}" in result
assert "Inserted content before section '## Details'" in result
assert f"[Session: Using project '{test_project.name}']" in result


@pytest.mark.asyncio
async def test_edit_note_insert_after_section_operation(client, test_project):
"""Test inserting content after a section heading."""
# Create initial note with sections
await write_note(
project=test_project.name,
title="Insert After Doc",
directory="docs",
content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.",
)

result = await edit_note(
project=test_project.name,
identifier="docs/insert-after-doc",
operation="insert_after_section",
content="Inserted after overview heading",
section="## Overview",
)

assert isinstance(result, str)
assert "Edited note (insert_after_section)" in result
assert f"project: {test_project.name}" in result
assert "Inserted content after section '## Overview'" in result
assert f"[Session: Using project '{test_project.name}']" in result


@pytest.mark.asyncio
async def test_edit_note_insert_before_section_missing_section(client, test_project):
"""Test insert_before_section without section parameter raises ValueError."""
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
content="# Test\nContent here.",
)

with pytest.raises(ValueError, match="section parameter is required"):
await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="insert_before_section",
content="new content",
)


@pytest.mark.asyncio
async def test_edit_note_insert_before_section_not_found(client, test_project):
"""Test insert_before_section when section doesn't exist returns error."""
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
content="# Test\n\n## Existing\nContent here.",
)

result = await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="insert_before_section",
content="new content",
section="## Nonexistent",
)

assert isinstance(result, str)
assert "# Edit Failed" in result
42 changes: 41 additions & 1 deletion tests/schemas/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ def test_edit_entity_request_find_replace_empty_find_text():
def test_edit_entity_request_replace_section_empty_section():
"""Test that replace_section operation requires non-empty section parameter."""
with pytest.raises(
ValueError, match="section parameter is required for replace_section operation"
ValueError, match="section parameter is required for section-based operations"
):
EditEntityRequest.model_validate(
{
Expand All @@ -356,6 +356,46 @@ def test_edit_entity_request_replace_section_empty_section():
)


def test_edit_entity_request_insert_before_section():
"""Test insert_before_section is a valid operation."""
edit_request = EditEntityRequest.model_validate(
{
"operation": "insert_before_section",
"content": "content to insert",
"section": "## Target Section",
}
)
assert edit_request.operation == "insert_before_section"
assert edit_request.section == "## Target Section"


def test_edit_entity_request_insert_after_section():
"""Test insert_after_section is a valid operation."""
edit_request = EditEntityRequest.model_validate(
{
"operation": "insert_after_section",
"content": "content to insert",
"section": "## Target Section",
}
)
assert edit_request.operation == "insert_after_section"
assert edit_request.section == "## Target Section"


def test_edit_entity_request_insert_before_section_empty_section():
"""Test that insert_before_section requires non-empty section parameter."""
with pytest.raises(
ValueError, match="section parameter is required for section-based operations"
):
EditEntityRequest.model_validate(
{
"operation": "insert_before_section",
"content": "content",
"section": "",
}
)


# New tests for timeframe parsing functions
class TestTimeframeParsing:
"""Test cases for parse_timeframe() and validate_timeframe() functions."""
Expand Down
Loading
Loading