diff --git a/src/basic_memory/services/context_service.py b/src/basic_memory/services/context_service.py index 2e897ac8..39d1aa7c 100644 --- a/src/basic_memory/services/context_service.py +++ b/src/basic_memory/services/context_service.py @@ -100,20 +100,30 @@ class ContextService: f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'" ) + normalized_path: Optional[str] = None if memory_url: path = memory_url_path(memory_url) - # Pattern matching - use search - if "*" in path: - logger.debug(f"Pattern search for '{path}'") - primary = await self.search_repository.search( - permalink_match=path, limit=limit, offset=offset - ) + # Check for wildcards before normalization + has_wildcard = "*" in path - # Direct lookup for exact path - else: - logger.debug(f"Direct lookup for '{path}'") + if has_wildcard: + # For wildcard patterns, normalize each segment separately to preserve the * + parts = path.split("*") + normalized_parts = [ + generate_permalink(part, split_extension=False) if part else "" + for part in parts + ] + normalized_path = "*".join(normalized_parts) + logger.debug(f"Pattern search for '{normalized_path}'") primary = await self.search_repository.search( - permalink=path, limit=limit, offset=offset + permalink_match=normalized_path, limit=limit, offset=offset + ) + else: + # For exact paths, normalize the whole thing + normalized_path = generate_permalink(path, split_extension=False) + logger.debug(f"Direct lookup for '{normalized_path}'") + primary = await self.search_repository.search( + permalink=normalized_path, limit=limit, offset=offset ) else: logger.debug(f"Build context for '{types}'") @@ -151,7 +161,7 @@ class ContextService: # Create metadata dataclass metadata = ContextMetadata( - uri=memory_url_path(memory_url) if memory_url else None, + uri=normalized_path if memory_url else None, types=types, depth=depth, timeframe=since.isoformat() if since else None, diff --git a/test-int/mcp/test_build_context_underscore.py b/test-int/mcp/test_build_context_underscore.py new file mode 100644 index 00000000..c5fd5a17 --- /dev/null +++ b/test-int/mcp/test_build_context_underscore.py @@ -0,0 +1,171 @@ +"""Integration test for build_context with underscore in memory:// URLs.""" + +import pytest +from fastmcp import Client + + +@pytest.mark.asyncio +async def test_build_context_underscore_normalization(mcp_server, app, test_project): + """Test that build_context normalizes underscores in relation types.""" + + async with Client(mcp_server) as client: + # Create parent note + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Parent Entity", + "folder": "testing", + "content": "# Parent Entity\n\nMain entity for testing underscore relations.", + "tags": "test,parent", + }, + ) + + # Create child notes with different relation formats + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Child with Underscore", + "folder": "testing", + "content": """# Child with Underscore + +- part_of [[Parent Entity]] +- related_to [[Parent Entity]] + """, + "tags": "test,child", + }, + ) + + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Child with Hyphen", + "folder": "testing", + "content": """# Child with Hyphen + +- part-of [[Parent Entity]] +- related-to [[Parent Entity]] + """, + "tags": "test,child", + }, + ) + + # Test 1: Search with underscore format should return results + # Relation permalinks are: source/relation_type/target + # So child-with-underscore/part-of/parent-entity + result_underscore = await client.call_tool( + "build_context", + { + "project": test_project.name, + "url": "memory://testing/*/part_of/*parent*", # Using underscore + }, + ) + + # Parse response + assert len(result_underscore.content) == 1 + response_text = result_underscore.content[0].text # pyright: ignore + assert '"results"' in response_text + + # Both relations should be found since they both connect to parent-entity + # The system should normalize the underscore to hyphen internally + assert "part-of" in response_text.lower() + + # Test 2: Search with hyphen format should also return results + result_hyphen = await client.call_tool( + "build_context", + { + "project": test_project.name, + "url": "memory://testing/*/part-of/*parent*", # Using hyphen + }, + ) + + response_text_hyphen = result_hyphen.content[0].text # pyright: ignore + assert '"results"' in response_text_hyphen + assert "part-of" in response_text_hyphen.lower() + + # Test 3: Test with related_to/related-to as well + result_related = await client.call_tool( + "build_context", + { + "project": test_project.name, + "url": "memory://testing/*/related_to/*parent*", # Using underscore + }, + ) + + response_text_related = result_related.content[0].text # pyright: ignore + assert '"results"' in response_text_related + assert "related-to" in response_text_related.lower() + + # Test 4: Test exact path (non-wildcard) with underscore + # Exact relation permalink would be child/relation/target + result_exact = await client.call_tool( + "build_context", + { + "project": test_project.name, + "url": "memory://testing/child-with-underscore/part_of/testing/parent-entity", + }, + ) + + response_text_exact = result_exact.content[0].text # pyright: ignore + assert '"results"' in response_text_exact + assert "part-of" in response_text_exact.lower() + + +@pytest.mark.asyncio +async def test_build_context_complex_underscore_paths(mcp_server, app, test_project): + """Test build_context with complex paths containing underscores.""" + + async with Client(mcp_server) as client: + # Create notes with underscores in titles and relations + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "workflow_manager_agent", + "folder": "specs", + "content": """# Workflow Manager Agent + +Specification for the workflow manager agent. + """, + "tags": "spec,workflow", + }, + ) + + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "task_parser", + "folder": "components", + "content": """# Task Parser + +- part_of [[workflow_manager_agent]] +- implements_for [[workflow_manager_agent]] + """, + "tags": "component,parser", + }, + ) + + # Test with underscores in all parts of the path + # Relations are created as: task-parser/part-of/workflow-manager-agent + # So search for */part_of/* or */part-of/* to find them + test_cases = [ + "memory://components/*/part_of/*workflow*", + "memory://components/*/part-of/*workflow*", + "memory://*/task*/part_of/*", + "memory://*/task*/part-of/*", + ] + + for url in test_cases: + result = await client.call_tool( + "build_context", {"project": test_project.name, "url": url} + ) + + # All variations should work and find the related content + assert len(result.content) == 1 + response = result.content[0].text # pyright: ignore + assert '"results"' in response + # The relation should be found showing part-of connection + assert "part-of" in response.lower(), f"Failed for URL: {url}"