Skip to content

Commit 3b78ea7

Browse files
phernandezclaude
andcommitted
merge: resolve conflict with main (insert_before/after_section tests)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
2 parents e9bf676 + ad3f265 commit 3b78ea7

9 files changed

Lines changed: 529 additions & 13 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ Basic Memory lets you build persistent knowledge through natural conversations w
2323
Claude, while keeping everything in simple Markdown files on your computer. It uses the Model Context Protocol (MCP) to
2424
enable any compatible LLM to read and write to your local knowledge base.
2525

26+
## What's New in v0.19.0
27+
28+
- **Semantic Vector Search** — find notes by meaning, not just keywords. Combines full-text and vector similarity for hybrid search with FastEmbed embeddings.
29+
- **Schema System** — infer, validate, and diff the structure of your knowledge base with `schema_infer`, `schema_validate`, and `schema_diff` tools.
30+
- **Per-Project Cloud Routing** — route individual projects through the cloud while others stay local, using API key authentication (`basic-memory project set-cloud`).
31+
- **FastMCP 3.0** — upgraded to FastMCP 3.0 with tool annotations for better client integration.
32+
- **CLI Overhaul** — JSON output mode (`--json`) for scripting, workspace-aware commands, and an htop-inspired project dashboard.
33+
- **Smarter Editing**`edit_note` append/prepend auto-creates notes if they don't exist; `write_note` has an overwrite guard to prevent accidental data loss.
34+
- **Richer Search Results** — matched chunk text returned in search results for better context.
35+
36+
See the full [CHANGELOG](CHANGELOG.md) for details.
37+
2638
- Website: [basicmemory.com](https://basicmemory.com?utm_source=github&utm_medium=referral&utm_campaign=readme)
2739
- Documentation: [docs.basicmemory.com](https://docs.basicmemory.com?utm_source=github&utm_medium=referral&utm_campaign=readme)
2840
- Community: [Discord](https://discord.gg/tyvKNccgqN?utm_source=github&utm_medium=referral&utm_campaign=readme)

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
@@ -322,7 +322,7 @@ async def test_edit_note_replace_section_missing_section(client, test_project):
322322
content="new content",
323323
)
324324

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

327327

328328
@pytest.mark.asyncio
@@ -677,3 +677,96 @@ async def test_edit_note_append_autocreate_not_fuzzy_match(client, test_project)
677677
content = await read_note("Existing Note Alpha", project=test_project.name)
678678
assert "Original content" in content
679679
assert "Brand new content" not in content
680+
681+
682+
@pytest.mark.asyncio
683+
async def test_edit_note_insert_before_section_operation(client, test_project):
684+
"""Test inserting content before a section heading."""
685+
# Create initial note with sections
686+
await write_note(
687+
project=test_project.name,
688+
title="Insert Before Doc",
689+
directory="docs",
690+
content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.",
691+
)
692+
693+
result = await edit_note(
694+
project=test_project.name,
695+
identifier="docs/insert-before-doc",
696+
operation="insert_before_section",
697+
content="--- inserted divider ---",
698+
section="## Details",
699+
)
700+
701+
assert isinstance(result, str)
702+
assert "Edited note (insert_before_section)" in result
703+
assert f"project: {test_project.name}" in result
704+
assert "Inserted content before section '## Details'" in result
705+
assert f"[Session: Using project '{test_project.name}']" in result
706+
707+
708+
@pytest.mark.asyncio
709+
async def test_edit_note_insert_after_section_operation(client, test_project):
710+
"""Test inserting content after a section heading."""
711+
# Create initial note with sections
712+
await write_note(
713+
project=test_project.name,
714+
title="Insert After Doc",
715+
directory="docs",
716+
content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.",
717+
)
718+
719+
result = await edit_note(
720+
project=test_project.name,
721+
identifier="docs/insert-after-doc",
722+
operation="insert_after_section",
723+
content="Inserted after overview heading",
724+
section="## Overview",
725+
)
726+
727+
assert isinstance(result, str)
728+
assert "Edited note (insert_after_section)" in result
729+
assert f"project: {test_project.name}" in result
730+
assert "Inserted content after section '## Overview'" in result
731+
assert f"[Session: Using project '{test_project.name}']" in result
732+
733+
734+
@pytest.mark.asyncio
735+
async def test_edit_note_insert_before_section_missing_section(client, test_project):
736+
"""Test insert_before_section without section parameter raises ValueError."""
737+
await write_note(
738+
project=test_project.name,
739+
title="Test Note",
740+
directory="test",
741+
content="# Test\nContent here.",
742+
)
743+
744+
with pytest.raises(ValueError, match="section parameter is required"):
745+
await edit_note(
746+
project=test_project.name,
747+
identifier="test/test-note",
748+
operation="insert_before_section",
749+
content="new content",
750+
)
751+
752+
753+
@pytest.mark.asyncio
754+
async def test_edit_note_insert_before_section_not_found(client, test_project):
755+
"""Test insert_before_section when section doesn't exist returns error."""
756+
await write_note(
757+
project=test_project.name,
758+
title="Test Note",
759+
directory="test",
760+
content="# Test\n\n## Existing\nContent here.",
761+
)
762+
763+
result = await edit_note(
764+
project=test_project.name,
765+
identifier="test/test-note",
766+
operation="insert_before_section",
767+
content="new content",
768+
section="## Nonexistent",
769+
)
770+
771+
assert isinstance(result, str)
772+
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)