diff --git a/src/basic_memory/api/routers/resource_router.py b/src/basic_memory/api/routers/resource_router.py index 28b4e87cb..7fd3b4045 100644 --- a/src/basic_memory/api/routers/resource_router.py +++ b/src/basic_memory/api/routers/resource_router.py @@ -31,7 +31,7 @@ def get_entity_ids(item: SearchIndexRow) -> list[int]: from_entity = item.from_id to_entity = item.to_id # pyright: ignore [reportReturnType] return [from_entity, to_entity] if to_entity else [from_entity] # pyright: ignore [reportReturnType] - case _: + case _: # pragma: no cover raise ValueError(f"Unexpected type: {item.type}") diff --git a/src/basic_memory/mcp/tools/notes.py b/src/basic_memory/mcp/tools/notes.py index 1d24a4012..21affe0b5 100644 --- a/src/basic_memory/mcp/tools/notes.py +++ b/src/basic_memory/mcp/tools/notes.py @@ -17,15 +17,14 @@ @mcp.tool( - description="Create or update a markdown note. Returns the permalink for referencing.", + description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.", ) async def write_note( title: str, content: str, folder: str, tags: Optional[List[str]] = None, - verbose: bool = False, -) -> EntityResponse | str: +) -> str: """Write a markdown note to the knowledge base. The content can include semantic observations and relations using markdown syntax. @@ -53,14 +52,16 @@ async def write_note( content: Markdown content for the note, can include observations and relations folder: the folder where the file should be saved tags: Optional list of tags to categorize the note - verbose: If True, returns full EntityResponse with semantic info Returns: - If verbose=False: Permalink that can be used to reference the note - If verbose=True: EntityResponse with full semantic details + A markdown formatted summary of the semantic content, including: + - Creation/update status + - File path and checksum + - Observation counts by category + - Relation counts (resolved/unresolved) + - Tags if present Examples: - # Note with both explicit and inline relations write_note( title="Search Implementation", content="# Search Component\\n\\n" @@ -73,20 +74,6 @@ async def write_note( "- depends_on [[Database Schema]]", folder="docs/components" ) - - # Note with tags - write_note( - title="Error Handling Design", - content="# Error Handling\\n\\n" - "This design builds on [[Reliability Design]].\\n\\n" - "## Approach\\n" - "- [design] Use error codes #architecture\\n" - "- [tech] Implement retry logic #implementation\\n\\n" - "## Relations\\n" - "- extends [[Base Error Handling]]", - folder="docs/design", - tags=["architecture", "reliability"] - ) """ logger.info(f"Writing note folder:'{folder}' title: '{title}'") @@ -101,12 +88,43 @@ async def write_note( entity_metadata=metadata, ) - # Use existing knowledge tool + # Create or update via knowledge API logger.info(f"Creating {entity.permalink}") url = f"/knowledge/entities/{entity.permalink}" response = await call_put(client, url, json=entity.model_dump()) result = EntityResponse.model_validate(response.json()) - return result if verbose else result.permalink + + # Format semantic summary based on status code + action = "Created" if response.status_code == 201 else "Updated" + assert result.checksum is not None + summary = [ + f"# {action} {result.file_path} ({result.checksum[:8]})", + f"permalink: {result.permalink}", + ] + + if result.observations: + categories = {} + for obs in result.observations: + categories[obs.category] = categories.get(obs.category, 0) + 1 + + summary.append("\n## Observations") + for category, count in sorted(categories.items()): + summary.append(f"- {category}: {count}") + + if result.relations: + unresolved = sum(1 for r in result.relations if not r.to_id) + resolved = len(result.relations) - unresolved + + summary.append("\n## Relations") + summary.append(f"- Resolved: {resolved}") + if unresolved: + summary.append(f"- Unresolved: {unresolved}") + summary.append("\nUnresolved relations will be retried on next sync.") + + if tags: + summary.append(f"\n## Tags\n- {', '.join(tags)}") + + return "\n".join(summary) @mcp.tool(description="Read note content by title, permalink, relation, or pattern") diff --git a/tests/api/test_resource_router.py b/tests/api/test_resource_router.py index 6a7689a9f..4daa823a9 100644 --- a/tests/api/test_resource_router.py +++ b/tests/api/test_resource_router.py @@ -158,7 +158,6 @@ async def test_get_resource_entities(client, test_config, entity_repository): entity2 = EntityResponse(**entity_response) assert len(entity2.relations) == 1 - relation = entity2.relations[0] # Test getting the content via the relation response = await client.get("/resource/test/*") diff --git a/tests/mcp/test_tool_get_entity.py b/tests/mcp/test_tool_get_entity.py deleted file mode 100644 index 233bf4c72..000000000 --- a/tests/mcp/test_tool_get_entity.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Tests for get_entity MCP tool.""" - -import pytest -from mcp.server.fastmcp.exceptions import ToolError - -from basic_memory.mcp.tools import notes -from basic_memory.mcp.tools.knowledge import get_entity - - -@pytest.mark.asyncio -async def test_get_basic_entity(client): - """Test retrieving a basic entity.""" - # First create an entity - permalink = await notes.write_note( - title="Test Note", - folder="test", - content=""" -# Test\nThis is a test note -- [note] First observation -""", - tags=["test", "documentation"], - ) - - assert permalink # Got a valid permalink - - # Get the entity without content - entity = await get_entity(permalink) - - # Verify entity details - assert entity.file_path == "test/Test Note.md" - assert entity.entity_type == "note" - assert entity.permalink == "test/test-note" - - # Check observations - assert len(entity.observations) == 1 - obs = entity.observations[0] - assert obs.content == "First observation" - assert obs.category == "note" - - -@pytest.mark.asyncio -async def test_get_nonexistent_entity(client): - """Test attempting to get a non-existent entity.""" - with pytest.raises(ToolError): - await get_entity("test/nonexistent") diff --git a/tests/mcp/test_tool_knowledge.py b/tests/mcp/test_tool_knowledge.py index 040a2eedf..085bba6f5 100644 --- a/tests/mcp/test_tool_knowledge.py +++ b/tests/mcp/test_tool_knowledge.py @@ -13,7 +13,7 @@ async def test_get_single_entity(client): """Test retrieving a single entity.""" # First create an entity - permalink = await notes.write_note( + result = await notes.write_note( title="Test Note", folder="test", content=""" @@ -22,9 +22,10 @@ async def test_get_single_entity(client): """, tags=["test", "documentation"], ) + assert result # Get the entity - entity = await get_entity(permalink) + entity = await get_entity("test/test-note") # Verify entity details assert entity.title == "Test Note" @@ -36,40 +37,40 @@ async def test_get_single_entity(client): async def test_get_multiple_entities(client): """Test retrieving multiple entities.""" # Create two test entities - permalink1 = await notes.write_note( + await notes.write_note( title="Test Note 1", folder="test", content="# Test 1", ) - permalink2 = await notes.write_note( + await notes.write_note( title="Test Note 2", folder="test", content="# Test 2", ) # Get both entities - request = GetEntitiesRequest(permalinks=[permalink1, permalink2]) + request = GetEntitiesRequest(permalinks=["test/test-note-1", "test/test-note-2"]) response = await get_entities(request) # Verify we got both entities assert len(response.entities) == 2 permalinks = {e.permalink for e in response.entities} - assert permalink1 in permalinks - assert permalink2 in permalinks + assert "test/test-note-1" in permalinks + assert "test/test-note-2" in permalinks @pytest.mark.asyncio async def test_delete_entities(client): """Test deleting entities.""" # Create a test entity - permalink = await notes.write_note( + await notes.write_note( title="Test Note", folder="test", content="# Test Note to Delete", ) # Delete the entity - request = DeleteEntitiesRequest(permalinks=[permalink]) + request = DeleteEntitiesRequest(permalinks=["test/test-note"]) response = await delete_entities(request) # Verify deletion @@ -77,7 +78,7 @@ async def test_delete_entities(client): # Verify entity no longer exists with pytest.raises(ToolError): - await get_entity(permalink) + await get_entity("test/test-note") @pytest.mark.asyncio diff --git a/tests/mcp/test_tool_notes.py b/tests/mcp/test_tool_notes.py index f31e92882..24be8faf4 100644 --- a/tests/mcp/test_tool_notes.py +++ b/tests/mcp/test_tool_notes.py @@ -1,10 +1,11 @@ """Tests for note tools that exercise the full stack with SQLite.""" +from textwrap import dedent + import pytest from mcp.server.fastmcp.exceptions import ToolError from basic_memory.mcp.tools import notes -from basic_memory.schemas import EntityResponse @pytest.mark.asyncio @@ -17,31 +18,41 @@ async def test_write_note(app): - Handle tags correctly - Return valid permalink """ - permalink = await notes.write_note( + result = await notes.write_note( title="Test Note", folder="test", content="# Test\nThis is a test note", tags=["test", "documentation"], ) - assert permalink # Got a valid permalink + assert result + assert ( + dedent(""" + # Created test/Test Note.md (159f2168) + permalink: test/test-note + + ## Tags + - test, documentation + """).strip() + in result + ) # Try reading it back via permalink - content = await notes.read_note(permalink) + content = await notes.read_note("test/test-note") assert ( - """ ---- -title: Test Note -type: note -permalink: test/test-note -tags: -- '#test' -- '#documentation' ---- - -# Test -This is a test note -""".strip() + dedent(""" + --- + title: Test Note + type: note + permalink: test/test-note + tags: + - '#test' + - '#documentation' + --- + + # Test + This is a test note + """).strip() in content ) @@ -49,20 +60,28 @@ async def test_write_note(app): @pytest.mark.asyncio async def test_write_note_no_tags(app): """Test creating a note without tags.""" - permalink = await notes.write_note(title="Simple Note", folder="test", content="Just some text") + result = await notes.write_note(title="Simple Note", folder="test", content="Just some text") + assert result + assert ( + dedent(""" + # Created test/Simple Note.md (9a1ff079) + permalink: test/simple-note + """).strip() + in result + ) # Should be able to read it back - content = await notes.read_note(permalink) + content = await notes.read_note("test/simple-note") assert ( - """ --- -title: Simple Note -type: note -permalink: test/simple-note ---- - -Just some text -""".strip() + dedent(""" + -- + title: Simple Note + type: note + permalink: test/simple-note + --- + + Just some text + """).strip() in content ) @@ -84,24 +103,44 @@ async def test_write_note_update_existing(app): - Handle tags correctly - Return valid permalink """ - permalink = await notes.write_note( + result = await notes.write_note( title="Test Note", folder="test", content="# Test\nThis is a test note", tags=["test", "documentation"], ) - assert permalink # Got a valid permalink + assert result # Got a valid permalink + assert ( + dedent(""" + # Created test/Test Note.md (159f2168) + permalink: test/test-note + + ## Tags + - test, documentation + """).strip() + in result + ) - permalink = await notes.write_note( + result = await notes.write_note( title="Test Note", folder="test", content="# Test\nThis is an updated note", tags=["test", "documentation"], ) + assert ( + dedent(""" + # Updated test/Test Note.md (131b5662) + permalink: test/test-note + + ## Tags + - test, documentation + """).strip() + in result + ) # Try reading it back - content = await notes.read_note(permalink) + content = await notes.read_note("test/test-note") assert ( """ --- @@ -135,10 +174,18 @@ async def test_read_note_by_title(app): async def test_note_unicode_content(app): """Test handling of unicode content in notes.""" content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦" - permalink = await notes.write_note(title="Unicode Test", folder="test", content=content) + result = await notes.write_note(title="Unicode Test", folder="test", content=content) + + assert ( + dedent(""" + # Created test/Unicode Test.md (272389cd) + permalink: test/unicode-test + """).strip() + in result + ) # Read back should preserve unicode - result = await notes.read_note(permalink) + result = await notes.read_note("test/unicode-test") assert content in result @@ -147,20 +194,32 @@ async def test_multiple_notes(app): """Test creating and managing multiple notes.""" # Create several notes notes_data = [ - ("Note 1", "test", "Content 1", ["tag1"]), - ("Note 2", "test", "Content 2", ["tag1", "tag2"]), - ("Note 3", "test", "Content 3", []), + ("test/note-1", "Note 1", "test", "Content 1", ["tag1"]), + ("test/note-2", "Note 2", "test", "Content 2", ["tag1", "tag2"]), + ("test/note-3", "Note 3", "test", "Content 3", []), ] - permalinks = [] - for title, folder, content, tags in notes_data: - permalink = await notes.write_note(title=title, folder=folder, content=content, tags=tags) - permalinks.append(permalink) + for _, title, folder, content, tags in notes_data: + await notes.write_note(title=title, folder=folder, content=content, tags=tags) # Should be able to read each one - for i, permalink in enumerate(permalinks): - content = await notes.read_note(permalink) - assert f"Content {i + 1}" in content + for permalink, title, folder, content, _ in notes_data: + note = await notes.read_note(permalink) + assert content in note + + # read multiple notes at once + + result = await notes.read_note("test/*") + + # note we can't compare times + assert "--- memory://test/note-1" in result + assert "Content 1" in result + + assert "--- memory://test/note-2" in result + assert "Content 2" in result + + assert "--- memory://test/note-3" in result + assert "Content 3" in result @pytest.mark.asyncio @@ -172,16 +231,16 @@ async def test_delete_note_existing(app): - Return valid permalink - Delete the note """ - permalink = await notes.write_note( + result = await notes.write_note( title="Test Note", folder="test", content="# Test\nThis is a test note", tags=["test", "documentation"], ) - assert permalink # Got a valid permalink + assert result - deleted = await notes.delete_note(permalink) + deleted = await notes.delete_note("test/test-note") assert deleted is True @@ -207,7 +266,7 @@ async def test_write_note_verbose(app): - Handle tags correctly - Return valid permalink """ - entity = await notes.write_note( + result = await notes.write_note( title="Test Note", folder="test", content=""" @@ -218,24 +277,27 @@ async def test_write_note_verbose(app): """, tags=["test", "documentation"], - verbose=True, ) - assert isinstance(entity, EntityResponse) - - assert entity.title == "Test Note" - assert entity.file_path == "test/Test Note.md" - assert entity.entity_type == "note" - assert entity.permalink == "test/test-note" - - assert len(entity.observations) == 1 - assert entity.observations[0].content == "First observation" - - assert len(entity.relations) == 1 - assert entity.relations[0].relation_type == "relates to" - assert entity.relations[0].from_id == "test/test-note" - assert entity.relations[0].to_id is None - assert entity.relations[0].to_name == "Knowledge" + assert ( + dedent(""" + # Created test/Test Note.md (06873a7a) + permalink: test/test-note + + ## Observations + - note: 1 + + ## Relations + - Resolved: 0 + - Unresolved: 1 + + Unresolved relations will be retried on next sync. + + ## Tags + - test, documentation + """).strip() + in result + ) @pytest.mark.asyncio @@ -248,13 +310,14 @@ async def test_read_note_memory_url(app): - Return the note content """ # First create a note - permalink = await notes.write_note( + result = await notes.write_note( title="Memory URL Test", folder="test", content="Testing memory:// URL handling", ) + assert result # Should be able to read it with a memory:// URL - memory_url = f"memory://{permalink}" + memory_url = "memory://test/memory-url-test" content = await notes.read_note(memory_url) assert "Testing memory:// URL handling" in content diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index 94b9d8118..a2b398be1 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -12,12 +12,13 @@ async def test_search_basic(client): """Test basic search functionality.""" # Create a test note - permalink = await notes.write_note( + result = await notes.write_note( title="Test Search Note", folder="test", content="# Test\nThis is a searchable test note", tags=["test", "search"], ) + assert result # Search for it query = SearchQuery(text="searchable") @@ -25,7 +26,7 @@ async def test_search_basic(client): # Verify results assert len(response.results) > 0 - assert any(r.permalink == permalink for r in response.results) + assert any(r.permalink == "test/test-search-note" for r in response.results) @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index 8f708d383..7d4c8b5d1 100644 --- a/uv.lock +++ b/uv.lock @@ -70,7 +70,7 @@ wheels = [ [[package]] name = "basic-memory" -version = "0.4.2" +version = "0.4.3" source = { editable = "." } dependencies = [ { name = "aiosqlite" },