diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py index caab7355..da115798 100644 --- a/src/basic_memory/mcp/tools/edit_note.py +++ b/src/basic_memory/mcp/tools/edit_note.py @@ -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( @@ -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. @@ -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)}" @@ -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: @@ -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 = {} diff --git a/src/basic_memory/schemas/request.py b/src/basic_memory/schemas/request.py index 877b16b1..2c4102d8 100644 --- a/src/basic_memory/schemas/request.py +++ b/src/basic_memory/schemas/request.py @@ -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 @@ -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") diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 4847367f..e6a1aff9 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -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}") @@ -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) + def _prepend_after_frontmatter(self, current_content: str, content: str) -> str: """Prepend content after frontmatter, preserving frontmatter structure.""" diff --git a/test-int/cli/test_cli_tool_edit_note_integration.py b/test-int/cli/test_cli_tool_edit_note_integration.py index d7d7a49a..7baa86e7 100644 --- a/test-int/cli/test_cli_tool_edit_note_integration.py +++ b/test-int/cli/test_cli_tool_edit_note_integration.py @@ -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( diff --git a/tests/mcp/test_tool_edit_note.py b/tests/mcp/test_tool_edit_note.py index 4d27f739..be8f19b5 100644 --- a/tests/mcp/test_tool_edit_note.py +++ b/tests/mcp/test_tool_edit_note.py @@ -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 @@ -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 diff --git a/tests/schemas/test_schemas.py b/tests/schemas/test_schemas.py index 6d7c4404..f97fe59c 100644 --- a/tests/schemas/test_schemas.py +++ b/tests/schemas/test_schemas.py @@ -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( { @@ -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.""" diff --git a/tests/services/test_entity_service.py b/tests/services/test_entity_service.py index e122000a..07a21db6 100644 --- a/tests/services/test_entity_service.py +++ b/tests/services/test_entity_service.py @@ -1402,6 +1402,267 @@ async def test_edit_entity_replace_section_strips_duplicate_header( assert "## Another Section" in file_content # Other sections preserved +# Insert before/after section tests +@pytest.mark.asyncio +async def test_edit_entity_insert_before_section( + entity_service: EntityService, file_service: FileService +): + """Test inserting content before a section heading.""" + content = dedent(""" + # Main Title + + ## Section 1 + Section 1 content + + ## Section 2 + Section 2 content + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Insert Before Test", + directory="docs", + note_type="note", + content=content, + ) + ) + + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="insert_before_section", + content="Inserted before section 2", + section="## Section 2", + ) + + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "Inserted before section 2" in file_content + assert "## Section 2" in file_content + assert "Section 2 content" in file_content + # Inserted content should appear before the section heading + assert file_content.index("Inserted before section 2") < file_content.index("## Section 2") + + +@pytest.mark.asyncio +async def test_edit_entity_insert_after_section( + entity_service: EntityService, file_service: FileService +): + """Test inserting content after a section heading.""" + content = dedent(""" + # Main Title + + ## Section 1 + Section 1 content + + ## Section 2 + Section 2 content + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Insert After Test", + directory="docs", + note_type="note", + content=content, + ) + ) + + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="insert_after_section", + content="Inserted after section 1 heading", + section="## Section 1", + ) + + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "Inserted after section 1 heading" in file_content + assert "## Section 1" in file_content + assert "Section 1 content" in file_content + # Inserted content should appear after the heading but content is also preserved + assert file_content.index("## Section 1") < file_content.index( + "Inserted after section 1 heading" + ) + + +@pytest.mark.asyncio +async def test_edit_entity_insert_before_section_not_found(entity_service: EntityService): + """Test insert_before_section raises ValueError when section not found.""" + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + directory="test", + note_type="note", + content="# Main Title\n\nSome content", + ) + ) + + with pytest.raises(ValueError, match="Section '## Missing' not found"): + await entity_service.edit_entity( + identifier=entity.permalink, + operation="insert_before_section", + content="new content", + section="## Missing", + ) + + +@pytest.mark.asyncio +async def test_edit_entity_insert_after_section_not_found(entity_service: EntityService): + """Test insert_after_section raises ValueError when section not found.""" + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + directory="test", + note_type="note", + content="# Main Title\n\nSome content", + ) + ) + + with pytest.raises(ValueError, match="Section '## Missing' not found"): + await entity_service.edit_entity( + identifier=entity.permalink, + operation="insert_after_section", + content="new content", + section="## Missing", + ) + + +@pytest.mark.asyncio +async def test_edit_entity_insert_before_section_multiple_sections_error( + entity_service: EntityService, +): + """Test insert_before_section raises ValueError with duplicate sections.""" + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + directory="test", + note_type="note", + content="# Title\n\n## Dup\nFirst\n\n## Dup\nSecond", + ) + ) + + with pytest.raises(ValueError, match="Multiple sections found"): + await entity_service.edit_entity( + identifier=entity.permalink, + operation="insert_before_section", + content="new content", + section="## Dup", + ) + + +@pytest.mark.asyncio +async def test_edit_entity_insert_before_section_missing_section_param( + entity_service: EntityService, +): + """Test insert_before_section raises ValueError when section param is missing.""" + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + directory="test", + note_type="note", + content="# Title\n\nContent", + ) + ) + + with pytest.raises(ValueError, match="section is required"): + await entity_service.edit_entity( + identifier=entity.permalink, + operation="insert_before_section", + content="new content", + ) + + +@pytest.mark.asyncio +async def test_edit_entity_insert_before_section_empty_section(entity_service: EntityService): + """Test insert_before_section raises ValueError when section is empty/whitespace.""" + entity = await entity_service.create_entity( + EntitySchema( + title="Test Note", + directory="test", + note_type="note", + content="# Title\n\nContent", + ) + ) + + with pytest.raises(ValueError, match="section cannot be empty"): + await entity_service.edit_entity( + identifier=entity.permalink, + operation="insert_before_section", + content="new content", + section=" ", + ) + + +@pytest.mark.asyncio +async def test_edit_entity_insert_after_section_at_end_of_document( + entity_service: EntityService, file_service: FileService +): + """Test inserting after the last section in a document.""" + content = dedent(""" + # Main Title + + ## Only Section + Some content here + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Insert End Test", + directory="docs", + note_type="note", + content=content, + ) + ) + + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="insert_after_section", + content="Inserted after the last section heading", + section="## Only Section", + ) + + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "Inserted after the last section heading" in file_content + assert "## Only Section" in file_content + assert "Some content here" in file_content + + +@pytest.mark.asyncio +async def test_edit_entity_insert_after_section_preserves_paragraph_separation( + entity_service: EntityService, file_service: FileService +): + """Test that insert_after_section adds blank line so inserted text doesn't merge + with existing section content into a single markdown paragraph.""" + content = dedent(""" + # Main Title + + ## Section + Existing paragraph text + """).strip() + + entity = await entity_service.create_entity( + EntitySchema( + title="Paragraph Sep Test", + directory="docs", + note_type="note", + content=content, + ) + ) + + updated = await entity_service.edit_entity( + identifier=entity.permalink, + operation="insert_after_section", + content="Inserted line", + section="## Section", + ) + + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + # The inserted line and existing content should be separated by a blank line + assert "Inserted line\n\nExisting paragraph text" in file_content + + # Move entity tests @pytest.mark.asyncio async def test_move_entity_success(