Skip to content

Commit ad3f265

Browse files
phernandezclaude
andauthored
feat: add insert_before_section and insert_after_section edit operations (#648)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d6508d9 commit ad3f265

7 files changed

Lines changed: 508 additions & 10 deletions

File tree

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def _format_error_response(
158158

159159

160160
@mcp.tool(
161-
description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
161+
description="Edit an existing markdown note using various operations like append, prepend, find_replace, replace_section, insert_before_section, or insert_after_section.",
162162
annotations={"destructiveHint": False, "openWorldHint": False},
163163
)
164164
async def edit_note(
@@ -190,6 +190,8 @@ async def edit_note(
190190
- "prepend": Add content to the beginning of the note (creates the note if it doesn't exist)
191191
- "find_replace": Replace occurrences of find_text with content (note must exist)
192192
- "replace_section": Replace content under a specific markdown header (note must exist)
193+
- "insert_before_section": Insert content before a section heading without consuming it (note must exist)
194+
- "insert_after_section": Insert content after a section heading without consuming it (note must exist)
193195
content: The content to add or use for replacement
194196
project: Project name to edit in. Optional - server will resolve using hierarchy.
195197
If unknown, use list_memory_projects() to discover available projects.
@@ -257,7 +259,14 @@ async def edit_note(
257259
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
258260

259261
# Validate operation
260-
valid_operations = ["append", "prepend", "find_replace", "replace_section"]
262+
valid_operations = [
263+
"append",
264+
"prepend",
265+
"find_replace",
266+
"replace_section",
267+
"insert_before_section",
268+
"insert_after_section",
269+
]
261270
if operation not in valid_operations:
262271
raise ValueError(
263272
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
@@ -266,8 +275,9 @@ async def edit_note(
266275
# Validate required parameters for specific operations
267276
if operation == "find_replace" and not find_text:
268277
raise ValueError("find_text parameter is required for find_replace operation")
269-
if operation == "replace_section" and not section:
270-
raise ValueError("section parameter is required for replace_section operation")
278+
section_ops = ("replace_section", "insert_before_section", "insert_after_section")
279+
if operation in section_ops and not section:
280+
raise ValueError("section parameter is required for section-based operations")
271281

272282
# Use the PATCH endpoint to edit the entity
273283
try:
@@ -389,6 +399,10 @@ async def edit_note(
389399
summary.append("operation: Find and replace operation completed")
390400
elif operation == "replace_section":
391401
summary.append(f"operation: Replaced content under section '{section}'")
402+
elif operation == "insert_before_section":
403+
summary.append(f"operation: Inserted content before section '{section}'")
404+
elif operation == "insert_after_section":
405+
summary.append(f"operation: Inserted content after section '{section}'")
392406

393407
# Count observations by category (reuse logic from write_note)
394408
categories = {}

src/basic_memory/schemas/request.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,14 @@ class EditEntityRequest(BaseModel):
6565
Supports various operation types for different editing scenarios.
6666
"""
6767

68-
operation: Literal["append", "prepend", "find_replace", "replace_section"]
68+
operation: Literal[
69+
"append",
70+
"prepend",
71+
"find_replace",
72+
"replace_section",
73+
"insert_before_section",
74+
"insert_after_section",
75+
]
6976
content: str
7077
section: Optional[str] = None
7178
find_text: Optional[str] = None
@@ -75,8 +82,16 @@ class EditEntityRequest(BaseModel):
7582
@classmethod
7683
def validate_section_for_replace_section(cls, v, info):
7784
"""Ensure section is provided for replace_section operation."""
78-
if info.data.get("operation") == "replace_section" and not v:
79-
raise ValueError("section parameter is required for replace_section operation")
85+
if (
86+
info.data.get("operation")
87+
in (
88+
"replace_section",
89+
"insert_before_section",
90+
"insert_after_section",
91+
)
92+
and not v
93+
):
94+
raise ValueError("section parameter is required for section-based operations")
8095
return v
8196

8297
@field_validator("find_text")

src/basic_memory/services/entity_service.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,14 @@ def apply_edit_operation(
888888
raise ValueError("section cannot be empty or whitespace only")
889889
return self.replace_section_content(current_content, section, content)
890890

891+
elif operation in ("insert_before_section", "insert_after_section"):
892+
if not section:
893+
raise ValueError("section is required for insert section operations")
894+
if not section.strip():
895+
raise ValueError("section cannot be empty or whitespace only")
896+
position = "before" if operation == "insert_before_section" else "after"
897+
return self.insert_relative_to_section(current_content, section, content, position)
898+
891899
else:
892900
raise ValueError(f"Unsupported operation: {operation}")
893901

@@ -979,6 +987,73 @@ def replace_section_content(
979987

980988
return "\n".join(result_lines)
981989

990+
def insert_relative_to_section(
991+
self,
992+
current_content: str,
993+
section_header: str,
994+
new_content: str,
995+
position: str,
996+
) -> str:
997+
"""Insert content before or after a section heading without consuming it.
998+
999+
Unlike replace_section_content, this preserves the section heading and its
1000+
existing content. The new content is inserted immediately before or after
1001+
the heading line.
1002+
1003+
Args:
1004+
current_content: The current markdown content
1005+
section_header: The section header to anchor on (e.g., "## Section Name")
1006+
new_content: The content to insert
1007+
position: "before" to insert above the heading, "after" to insert below it
1008+
1009+
Returns:
1010+
The updated content with new_content inserted relative to the heading
1011+
1012+
Raises:
1013+
ValueError: If the section header is not found or appears more than once
1014+
"""
1015+
# Normalize the section header (ensure it starts with #)
1016+
if not section_header.startswith("#"):
1017+
section_header = "## " + section_header
1018+
1019+
lines = current_content.split("\n")
1020+
matching_indices = [
1021+
i for i, line in enumerate(lines) if line.strip() == section_header.strip()
1022+
]
1023+
1024+
if len(matching_indices) == 0:
1025+
raise ValueError(
1026+
f"Section '{section_header}' not found in document. "
1027+
f"Use replace_section to create a new section."
1028+
)
1029+
if len(matching_indices) > 1:
1030+
raise ValueError(
1031+
f"Multiple sections found with header '{section_header}'. "
1032+
f"Section insertion requires unique headers."
1033+
)
1034+
1035+
idx = matching_indices[0]
1036+
1037+
if position == "before":
1038+
# Insert new content before the section heading
1039+
before = lines[:idx]
1040+
after = lines[idx:]
1041+
# Ensure blank line separation
1042+
insert_lines = new_content.rstrip("\n").split("\n")
1043+
if before and before[-1].strip() != "":
1044+
insert_lines = [""] + insert_lines
1045+
return "\n".join(before + insert_lines + [""] + after)
1046+
else:
1047+
# Insert new content after the section heading line
1048+
before = lines[: idx + 1]
1049+
after = lines[idx + 1 :]
1050+
insert_lines = new_content.rstrip("\n").split("\n")
1051+
# Ensure blank line separation so inserted text doesn't merge
1052+
# with existing section content into a single paragraph
1053+
if after and after[0].strip() != "":
1054+
insert_lines = insert_lines + [""]
1055+
return "\n".join(before + insert_lines + after)
1056+
9821057
def _prepend_after_frontmatter(self, current_content: str, content: str) -> str:
9831058
"""Prepend content after frontmatter, preserving frontmatter structure."""
9841059

test-int/cli/test_cli_tool_edit_note_integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ def test_edit_note_replace_section_fails_without_section(
208208
)
209209

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

213213

214214
def test_edit_note_append_creates_nonexistent_note_cli(

tests/mcp/test_tool_edit_note.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ async def test_edit_note_replace_section_missing_section(client, test_project):
320320
content="new content",
321321
)
322322

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

325325

326326
@pytest.mark.asyncio
@@ -611,3 +611,96 @@ async def test_edit_note_preserves_permalink_when_frontmatter_missing(client, te
611611
assert f"permalink: {test_project.name}/test/test-note" in second_result
612612
assert f"[Session: Using project '{test_project.name}']" in second_result
613613
# The edit should succeed without validation errors
614+
615+
616+
@pytest.mark.asyncio
617+
async def test_edit_note_insert_before_section_operation(client, test_project):
618+
"""Test inserting content before a section heading."""
619+
# Create initial note with sections
620+
await write_note(
621+
project=test_project.name,
622+
title="Insert Before Doc",
623+
directory="docs",
624+
content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.",
625+
)
626+
627+
result = await edit_note(
628+
project=test_project.name,
629+
identifier="docs/insert-before-doc",
630+
operation="insert_before_section",
631+
content="--- inserted divider ---",
632+
section="## Details",
633+
)
634+
635+
assert isinstance(result, str)
636+
assert "Edited note (insert_before_section)" in result
637+
assert f"project: {test_project.name}" in result
638+
assert "Inserted content before section '## Details'" in result
639+
assert f"[Session: Using project '{test_project.name}']" in result
640+
641+
642+
@pytest.mark.asyncio
643+
async def test_edit_note_insert_after_section_operation(client, test_project):
644+
"""Test inserting content after a section heading."""
645+
# Create initial note with sections
646+
await write_note(
647+
project=test_project.name,
648+
title="Insert After Doc",
649+
directory="docs",
650+
content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.",
651+
)
652+
653+
result = await edit_note(
654+
project=test_project.name,
655+
identifier="docs/insert-after-doc",
656+
operation="insert_after_section",
657+
content="Inserted after overview heading",
658+
section="## Overview",
659+
)
660+
661+
assert isinstance(result, str)
662+
assert "Edited note (insert_after_section)" in result
663+
assert f"project: {test_project.name}" in result
664+
assert "Inserted content after section '## Overview'" in result
665+
assert f"[Session: Using project '{test_project.name}']" in result
666+
667+
668+
@pytest.mark.asyncio
669+
async def test_edit_note_insert_before_section_missing_section(client, test_project):
670+
"""Test insert_before_section without section parameter raises ValueError."""
671+
await write_note(
672+
project=test_project.name,
673+
title="Test Note",
674+
directory="test",
675+
content="# Test\nContent here.",
676+
)
677+
678+
with pytest.raises(ValueError, match="section parameter is required"):
679+
await edit_note(
680+
project=test_project.name,
681+
identifier="test/test-note",
682+
operation="insert_before_section",
683+
content="new content",
684+
)
685+
686+
687+
@pytest.mark.asyncio
688+
async def test_edit_note_insert_before_section_not_found(client, test_project):
689+
"""Test insert_before_section when section doesn't exist returns error."""
690+
await write_note(
691+
project=test_project.name,
692+
title="Test Note",
693+
directory="test",
694+
content="# Test\n\n## Existing\nContent here.",
695+
)
696+
697+
result = await edit_note(
698+
project=test_project.name,
699+
identifier="test/test-note",
700+
operation="insert_before_section",
701+
content="new content",
702+
section="## Nonexistent",
703+
)
704+
705+
assert isinstance(result, str)
706+
assert "# Edit Failed" in result

tests/schemas/test_schemas.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ def test_edit_entity_request_find_replace_empty_find_text():
345345
def test_edit_entity_request_replace_section_empty_section():
346346
"""Test that replace_section operation requires non-empty section parameter."""
347347
with pytest.raises(
348-
ValueError, match="section parameter is required for replace_section operation"
348+
ValueError, match="section parameter is required for section-based operations"
349349
):
350350
EditEntityRequest.model_validate(
351351
{
@@ -356,6 +356,46 @@ def test_edit_entity_request_replace_section_empty_section():
356356
)
357357

358358

359+
def test_edit_entity_request_insert_before_section():
360+
"""Test insert_before_section is a valid operation."""
361+
edit_request = EditEntityRequest.model_validate(
362+
{
363+
"operation": "insert_before_section",
364+
"content": "content to insert",
365+
"section": "## Target Section",
366+
}
367+
)
368+
assert edit_request.operation == "insert_before_section"
369+
assert edit_request.section == "## Target Section"
370+
371+
372+
def test_edit_entity_request_insert_after_section():
373+
"""Test insert_after_section is a valid operation."""
374+
edit_request = EditEntityRequest.model_validate(
375+
{
376+
"operation": "insert_after_section",
377+
"content": "content to insert",
378+
"section": "## Target Section",
379+
}
380+
)
381+
assert edit_request.operation == "insert_after_section"
382+
assert edit_request.section == "## Target Section"
383+
384+
385+
def test_edit_entity_request_insert_before_section_empty_section():
386+
"""Test that insert_before_section requires non-empty section parameter."""
387+
with pytest.raises(
388+
ValueError, match="section parameter is required for section-based operations"
389+
):
390+
EditEntityRequest.model_validate(
391+
{
392+
"operation": "insert_before_section",
393+
"content": "content",
394+
"section": "",
395+
}
396+
)
397+
398+
359399
# New tests for timeframe parsing functions
360400
class TestTimeframeParsing:
361401
"""Test cases for parse_timeframe() and validate_timeframe() functions."""

0 commit comments

Comments
 (0)