diff --git a/claude-output/claude_error_75.log b/claude-output/claude_error_75.log new file mode 100644 index 000000000..e69de29bb diff --git a/claude-output/claude_issue_fix_log_75.txt b/claude-output/claude_issue_fix_log_75.txt new file mode 100644 index 000000000..b4c258ad9 --- /dev/null +++ b/claude-output/claude_issue_fix_log_75.txt @@ -0,0 +1,13 @@ +Starting issue-fix mode at Sat Apr 5 18:02:54 UTC 2025 +Fetching issue #75 details +Using repository: basicmachines-co/basic-memory +Checking if phernandez is a member of organization basicmachines-co +User phernandez is a member of organization basicmachines-co. Proceeding with Claude fix. +Creating a new branch: fix-issue-75-20250405180254 +From https://github.com/basicmachines-co/basic-memory + * branch main -> FETCH_HEAD +Switched to a new branch 'fix-issue-75-20250405180254' +branch 'fix-issue-75-20250405180254' set up to track 'origin/main'. +Prompt saved to ./claude-output/claude_prompt_75.txt for debugging +Running Claude to fix the issue... +Committing changes... diff --git a/claude-output/claude_output_75.txt b/claude-output/claude_output_75.txt new file mode 100644 index 000000000..ba0bfa42e --- /dev/null +++ b/claude-output/claude_output_75.txt @@ -0,0 +1,35 @@ +Let's summarize the changes we've made to fix issue #75: + +1. We updated the `search_notes` tool in `/src/basic_memory/mcp/tools/search.py` to accept primitive types as parameters instead of a complex Pydantic `SearchQuery` object. This makes it easier for LLMs like Cursor to make proper tool calls. + +2. We converted the internal implementation to create a SearchQuery object from the primitive parameters, maintaining backward compatibility with the existing API. + +3. We updated tests in `/tests/mcp/test_tool_search.py` to use the new function signature with primitive parameters. + +4. We updated code in `/src/basic_memory/mcp/tools/read_note.py` to use the new function signature when making calls to `search_notes`. + +5. We updated code in `/src/basic_memory/mcp/prompts/search.py` to use the new function signature when making calls to `search_notes`. + +These changes should make it easier for Cursor and other LLMs to use the search_notes tool by eliminating the complex Pydantic object parameter in favor of simple primitive parameters. + +---SUMMARY--- +Fixed issue #75 where Cursor was having errors calling the search_notes tool. The problem was that the search_notes tool was expecting a complex Pydantic object (SearchQuery) as input, which was confusing Cursor. + +Changes: +1. Modified the search_notes tool to accept primitive types (strings, lists, etc.) as parameters instead of a complex Pydantic object +2. Updated the implementation to create a SearchQuery object internally from these primitive parameters +3. Updated all call sites in the codebase that were using the old function signature +4. Updated tests to use the new function signature + +The fix makes it easier for LLMs like Cursor to make proper calls to the search_notes tool, which will resolve the reported error messages: +- "Parameter 'query' must be of type undefined, got object" +- "Parameter 'query' must be of type undefined, got string" +- "Invalid type for parameter 'query' in tool search_notes" + +Files modified: +- src/basic_memory/mcp/tools/search.py +- src/basic_memory/mcp/tools/read_note.py +- src/basic_memory/mcp/prompts/search.py +- tests/mcp/test_tool_search.py +- tests/mcp/test_tool_read_note.py +---END SUMMARY--- \ No newline at end of file diff --git a/claude-output/claude_prompt_75.txt b/claude-output/claude_prompt_75.txt new file mode 100644 index 000000000..70e1696c9 --- /dev/null +++ b/claude-output/claude_prompt_75.txt @@ -0,0 +1,49 @@ +You are Claude, an AI assistant tasked with fixing issues in a GitHub repository. + +Issue #75: [BUG] Cursor has errors calling search tool + +Issue Description: +## Bug Description + + + +> Cursor cannot figure out how to structure the parameters for that tool call. No matter what Cursor seems to try it gets the errors. +> +> ```Looking at the error messages more carefully: +> - When I pass an object: "Parameter 'query' must be of type undefined, got object" +> - When I pass a string: "Parameter 'query' must be of type undefined, got string" +> +> +> +> and then it reports: "Invalid type for parameter 'query' in tool search_notes" +> Any chance you can give me some guidance with this? +> + +## Steps To Reproduce +Steps to reproduce the behavior: + +try using search tool in Cursor. + +## Possible Solution + +The tool args should probably be plain text and not json to make it easier to call. +Additional Instructions from User Comment: + let make a PR to implement option #1. +Your task is to: +1. Analyze the issue carefully to understand the problem +2. Look through the repository to identify the relevant files that need to be modified +3. Make precise changes to fix the issue +4. Use the Edit tool to modify files directly when needed +5. Be minimal in your changes - only modify what's necessary to fix the issue + +After making changes, provide a summary of what you did in this format: + +---SUMMARY--- +[Your detailed summary of changes, including which files were modified and how] +---END SUMMARY--- + +Remember: +- Be specific in your changes +- Only modify files that are necessary to fix the issue +- Follow existing code style and conventions +- Make the minimal changes needed to resolve the issue diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 9df2b2f32..c733fedcb 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -2,31 +2,29 @@ import asyncio import sys -from typing import Optional, List, Annotated +from typing import Annotated, List, Optional import typer from loguru import logger from rich import print as rprint from basic_memory.cli.app import app -from basic_memory.mcp.tools import build_context as mcp_build_context -from basic_memory.mcp.tools import read_note as mcp_read_note -from basic_memory.mcp.tools import recent_activity as mcp_recent_activity -from basic_memory.mcp.tools import search_notes as mcp_search -from basic_memory.mcp.tools import write_note as mcp_write_note # Import prompts from basic_memory.mcp.prompts.continue_conversation import ( continue_conversation as mcp_continue_conversation, ) - from basic_memory.mcp.prompts.recent_activity import ( recent_activity_prompt as recent_activity_prompt, ) - +from basic_memory.mcp.tools import build_context as mcp_build_context +from basic_memory.mcp.tools import read_note as mcp_read_note +from basic_memory.mcp.tools import recent_activity as mcp_recent_activity +from basic_memory.mcp.tools import search_notes as mcp_search +from basic_memory.mcp.tools import write_note as mcp_write_note from basic_memory.schemas.base import TimeFrame from basic_memory.schemas.memory import MemoryUrl -from basic_memory.schemas.search import SearchQuery, SearchItemType +from basic_memory.schemas.search import SearchItemType tool_app = typer.Typer() app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI") @@ -198,13 +196,28 @@ def search_notes( raise typer.Abort() try: - search_query = SearchQuery( - permalink_match=query if permalink else None, - text=query if not (permalink or title) else None, - title=query if title else None, - after_date=after_date, + if permalink and title: # pragma: no cover + typer.echo( + "Use either --permalink or --title, not both. Exiting.", + err=True, + ) + raise typer.Exit(1) + + # set search type + search_type = ("permalink" if permalink else None,) + search_type = ("permalink_match" if permalink and "*" in query else None,) + search_type = ("title" if title else None,) + search_type = "text" if search_type is None else search_type + + results = asyncio.run( + mcp_search( + query, + search_type=search_type, + page=page, + after_date=after_date, + page_size=page_size, + ) ) - results = asyncio.run(mcp_search(query=search_query, page=page, page_size=page_size)) # Use json module for more controlled serialization import json diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index b4c01a1f1..f09292691 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -234,6 +234,7 @@ def get_process_name(): # pragma: no cover # Global flag to track if logging has been set up _LOGGING_SETUP = False + def setup_basic_memory_logging(): # pragma: no cover """Set up logging for basic-memory, ensuring it only happens once.""" global _LOGGING_SETUP diff --git a/src/basic_memory/mcp/prompts/continue_conversation.py b/src/basic_memory/mcp/prompts/continue_conversation.py index d25961952..d85e1c192 100644 --- a/src/basic_memory/mcp/prompts/continue_conversation.py +++ b/src/basic_memory/mcp/prompts/continue_conversation.py @@ -5,19 +5,19 @@ """ from textwrap import dedent -from typing import Optional, Annotated +from typing import Annotated, Optional from loguru import logger from pydantic import Field -from basic_memory.mcp.prompts.utils import format_prompt_context, PromptContext, PromptContextItem +from basic_memory.mcp.prompts.utils import PromptContext, PromptContextItem, format_prompt_context from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.build_context import build_context from basic_memory.mcp.tools.recent_activity import recent_activity from basic_memory.mcp.tools.search import search_notes from basic_memory.schemas.base import TimeFrame from basic_memory.schemas.memory import GraphContext -from basic_memory.schemas.search import SearchQuery, SearchItemType +from basic_memory.schemas.search import SearchItemType @mcp.prompt( @@ -48,7 +48,7 @@ async def continue_conversation( # If topic provided, search for it if topic: search_results = await search_notes( - SearchQuery(text=topic, after_date=timeframe, types=[SearchItemType.ENTITY]) + query=topic, after_date=timeframe, entity_types=[SearchItemType.ENTITY] ) # Build context from results diff --git a/src/basic_memory/mcp/prompts/search.py b/src/basic_memory/mcp/prompts/search.py index 486d049b5..7e3a44f7b 100644 --- a/src/basic_memory/mcp/prompts/search.py +++ b/src/basic_memory/mcp/prompts/search.py @@ -12,7 +12,7 @@ from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.search import search_notes as search_tool from basic_memory.schemas.base import TimeFrame -from basic_memory.schemas.search import SearchQuery, SearchResponse +from basic_memory.schemas.search import SearchResponse @mcp.prompt( @@ -40,7 +40,7 @@ async def search_prompt( """ logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}") - search_results = await search_tool(SearchQuery(text=query, after_date=timeframe)) + search_results = await search_tool(query=query, after_date=timeframe) return format_search_results(query, search_results, timeframe) diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index 44c53743e..3e6629a29 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -9,7 +9,6 @@ from basic_memory.mcp.tools.search import search_notes from basic_memory.mcp.tools.utils import call_get from basic_memory.schemas.memory import memory_url_path -from basic_memory.schemas.search import SearchQuery @mcp.tool( @@ -63,7 +62,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str: # Fallback 1: Try title search via API logger.info(f"Search title for: {identifier}") - title_results = await search_notes(SearchQuery(title=identifier)) + title_results = await search_notes(query=identifier, search_type="title") if title_results and title_results.results: result = title_results.results[0] # Get the first/best match @@ -87,7 +86,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str: # Fallback 2: Text search as a last resort logger.info(f"Title search failed, trying text search for: {identifier}") - text_results = await search_notes(SearchQuery(text=identifier)) + text_results = await search_notes(query=identifier, search_type="text") # We didn't find a direct match, construct a helpful error message if not text_results or not text_results.results: diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 9eb4644bb..0fdb02d8c 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -1,17 +1,27 @@ """Search tools for Basic Memory MCP server.""" +from typing import List, Optional + from loguru import logger +from basic_memory.mcp.async_client import client from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.utils import call_post -from basic_memory.schemas.search import SearchQuery, SearchResponse -from basic_memory.mcp.async_client import client +from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse @mcp.tool( description="Search across all content in the knowledge base.", ) -async def search_notes(query: SearchQuery, page: int = 1, page_size: int = 10) -> SearchResponse: +async def search_notes( + query: str, + page: int = 1, + page_size: int = 10, + search_type: str = "text", + types: Optional[List[str]] = None, + entity_types: Optional[List[str]] = None, + after_date: Optional[str] = None, +) -> SearchResponse: """Search across all content in the knowledge base. This tool searches the knowledge base using full-text search, pattern matching, @@ -19,59 +29,85 @@ async def search_notes(query: SearchQuery, page: int = 1, page_size: int = 10) - and date. Args: - query: SearchQuery object with search parameters including: - - text: Full-text search (e.g., "project planning") - Supports boolean operators: AND, OR, NOT and parentheses for grouping - - title: Search only in titles (e.g., "Meeting notes") - - permalink: Exact permalink match (e.g., "docs/meeting-notes") - - permalink_match: Pattern matching for permalinks (e.g., "docs/*-notes") - - types: Optional list of content types to search (e.g., ["entity", "observation"]) - - entity_types: Optional list of entity types to filter by (e.g., ["note", "person"]) - - after_date: Optional date filter for recent content (e.g., "1 week", "2d") + query: The search query string page: The page number of results to return (default 1) page_size: The number of results to return per page (default 10) + search_type: Type of search to perform, one of: "text", "title", "permalink" (default: "text") + types: Optional list of note types to search (e.g., ["note", "person"]) + entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"]) + after_date: Optional date filter for recent content (e.g., "1 week", "2d") Returns: SearchResponse with results and pagination info Examples: # Basic text search - results = await search_notes(SearchQuery(text="project planning")) + results = await search_notes("project planning") # Boolean AND search (both terms must be present) - results = await search_notes(SearchQuery(text="project AND planning")) + results = await search_notes("project AND planning") # Boolean OR search (either term can be present) - results = await search_notes(SearchQuery(text="project OR meeting")) + results = await search_notes("project OR meeting") # Boolean NOT search (exclude terms) - results = await search_notes(SearchQuery(text="project NOT meeting")) + results = await search_notes("project NOT meeting") # Boolean search with grouping - results = await search_notes(SearchQuery(text="(project OR planning) AND notes")) + results = await search_notes("(project OR planning) AND notes") # Search with type filter - results = await search_notes(SearchQuery( - text="meeting notes", + results = await search_notes( + query="meeting notes", + types=["entity"], + ) + + # Search with entity type filter, e.g., note vs + results = await search_notes( + query="meeting notes", types=["entity"], - )) + ) # Search for recent content - results = await search_notes(SearchQuery( - text="bug report", + results = await search_notes( + query="bug report", after_date="1 week" - )) + ) # Pattern matching on permalinks - results = await search_notes(SearchQuery( - permalink_match="docs/meeting-*" - )) + results = await search_notes( + query="docs/meeting-*", + search_type="permalink" + ) """ - logger.info(f"Searching for {query}") + # Create a SearchQuery object based on the parameters + search_query = SearchQuery() + + # Set the appropriate search field based on search_type + if search_type == "text": + search_query.text = query + elif search_type == "title": + search_query.title = query + elif search_type == "permalink" and "*" in query: + search_query.permalink_match = query + elif search_type == "permalink": + search_query.permalink = query + else: + search_query.text = query # Default to text search + + # Add optional filters if provided + if entity_types: + search_query.entity_types = [SearchItemType(t) for t in entity_types] + if types: + search_query.types = types + if after_date: + search_query.after_date = after_date + + logger.info(f"Searching for {search_query}") response = await call_post( client, "/search/", - json=query.model_dump(), + json=search_query.model_dump(), params={"page": page, "page_size": page_size}, ) return SearchResponse.model_validate(response.json()) diff --git a/src/basic_memory/repository/search_repository.py b/src/basic_memory/repository/search_repository.py index d733f1bd7..2d12b4dcf 100644 --- a/src/basic_memory/repository/search_repository.py +++ b/src/basic_memory/repository/search_repository.py @@ -4,10 +4,10 @@ import time from dataclasses import dataclass from datetime import datetime -from typing import List, Optional, Any, Dict +from typing import Any, Dict, List, Optional from loguru import logger -from sqlalchemy import text, Executable, Result +from sqlalchemy import Executable, Result, text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from basic_memory import db @@ -123,9 +123,9 @@ async def search( permalink: Optional[str] = None, permalink_match: Optional[str] = None, title: Optional[str] = None, - types: Optional[List[SearchItemType]] = None, + types: Optional[List[str]] = None, after_date: Optional[datetime] = None, - entity_types: Optional[List[str]] = None, + entity_types: Optional[List[SearchItemType]] = None, limit: int = 10, offset: int = 0, ) -> List[SearchIndexRow]: @@ -174,15 +174,15 @@ async def search( else: conditions.append("permalink MATCH :permalink") - # Handle type filter - if types: - type_list = ", ".join(f"'{t.value}'" for t in types) - conditions.append(f"type IN ({type_list})") - # Handle entity type filter if entity_types: - entity_type_list = ", ".join(f"'{t}'" for t in entity_types) - conditions.append(f"json_extract(metadata, '$.entity_type') IN ({entity_type_list})") + type_list = ", ".join(f"'{t.value}'" for t in entity_types) + conditions.append(f"type IN ({type_list})") + + # Handle type filter + if types: + type_list = ", ".join(f"'{t}'" for t in types) + conditions.append(f"json_extract(metadata, '$.entity_type') IN ({type_list})") # Handle date filter using datetime() for proper comparison if after_date: diff --git a/src/basic_memory/schemas/search.py b/src/basic_memory/schemas/search.py index 04b4dd3b6..a27bfce06 100644 --- a/src/basic_memory/schemas/search.py +++ b/src/basic_memory/schemas/search.py @@ -49,8 +49,8 @@ class SearchQuery(BaseModel): title: Optional[str] = None # title only search # Optional filters - types: Optional[List[SearchItemType]] = None # Filter by item type - entity_types: Optional[List[str]] = None # Filter by entity type + types: Optional[List[str]] = None # Filter by type + entity_types: Optional[List[SearchItemType]] = None # Filter by entity type after_date: Optional[Union[datetime, str]] = None # Time-based filter @field_validator("after_date") diff --git a/src/basic_memory/services/context_service.py b/src/basic_memory/services/context_service.py index 558c34814..31d7653a8 100644 --- a/src/basic_memory/services/context_service.py +++ b/src/basic_memory/services/context_service.py @@ -81,7 +81,7 @@ async def build_context( else: logger.debug(f"Build context for '{types}'") primary = await self.search_repository.search( - types=types, after_date=since, limit=limit, offset=offset + entity_types=types, after_date=since, limit=limit, offset=offset ) # Get type_id pairs for traversal diff --git a/src/basic_memory/services/link_resolver.py b/src/basic_memory/services/link_resolver.py index febe372af..541aed2fb 100644 --- a/src/basic_memory/services/link_resolver.py +++ b/src/basic_memory/services/link_resolver.py @@ -46,10 +46,17 @@ async def resolve_link(self, link_text: str, use_search: bool = True) -> Optiona logger.debug(f"Found title match: {entity.title}") return entity + # 3. Try file path + found_path = await self.entity_repository.get_by_file_path(clean_text) + if found_path: + logger.debug(f"Found entity with path: {found_path.file_path}") + return found_path + + # search if indicated if use_search and "*" not in clean_text: # 3. Fall back to search for fuzzy matching on title results = await self.search_service.search( - query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]), + query=SearchQuery(title=clean_text, entity_types=[SearchItemType.ENTITY]), ) if results: diff --git a/tests/api/test_knowledge_router.py b/tests/api/test_knowledge_router.py index 91e3134c8..2aa19a296 100644 --- a/tests/api/test_knowledge_router.py +++ b/tests/api/test_knowledge_router.py @@ -251,7 +251,7 @@ async def test_entity_indexing(client: AsyncClient): # Verify it's searchable search_response = await client.post( - "/search/", json={"text": "search", "types": [SearchItemType.ENTITY.value]} + "/search/", json={"text": "search", "entity_types": [SearchItemType.ENTITY.value]} ) assert search_response.status_code == 200 search_result = SearchResponse.model_validate(search_response.json()) @@ -279,7 +279,7 @@ async def test_entity_delete_indexing(client: AsyncClient): # Verify it's initially searchable search_response = await client.post( - "/search/", json={"text": "delete", "types": [SearchItemType.ENTITY.value]} + "/search/", json={"text": "delete", "entity_types": [SearchItemType.ENTITY.value]} ) search_result = SearchResponse.model_validate(search_response.json()) assert len(search_result.results) == 1 @@ -475,7 +475,7 @@ async def test_update_entity_search_index(client: AsyncClient): # Search should find new content search_response = await client.post( - "/search/", json={"text": "sphinx marker", "types": [SearchItemType.ENTITY.value]} + "/search/", json={"text": "sphinx marker", "entity_types": [SearchItemType.ENTITY.value]} ) results = search_response.json()["results"] assert len(results) == 1 diff --git a/tests/api/test_search_router.py b/tests/api/test_search_router.py index 7577d0247..59c3a8d76 100644 --- a/tests/api/test_search_router.py +++ b/tests/api/test_search_router.py @@ -48,11 +48,11 @@ async def test_search_basic_pagination(client, indexed_entity): @pytest.mark.asyncio -async def test_search_with_type_filter(client, indexed_entity): +async def test_search_with_entity_type_filter(client, indexed_entity): """Test search with type filter.""" # Should find with correct type response = await client.post( - "/search/", json={"text": "test", "types": [SearchItemType.ENTITY.value]} + "/search/", json={"text": "test", "entity_types": [SearchItemType.ENTITY.value]} ) assert response.status_code == 200 search_results = SearchResponse.model_validate(response.json()) @@ -60,7 +60,7 @@ async def test_search_with_type_filter(client, indexed_entity): # Should find with relation type response = await client.post( - "/search/", json={"text": "test", "types": [SearchItemType.RELATION.value]} + "/search/", json={"text": "test", "entity_types": [SearchItemType.RELATION.value]} ) assert response.status_code == 200 search_results = SearchResponse.model_validate(response.json()) @@ -68,16 +68,16 @@ async def test_search_with_type_filter(client, indexed_entity): @pytest.mark.asyncio -async def test_search_with_entity_type_filter(client, indexed_entity): +async def test_search_with_type_filter(client, indexed_entity): """Test search with entity type filter.""" # Should find with correct entity type - response = await client.post("/search/", json={"text": "test", "entity_types": ["test"]}) + response = await client.post("/search/", json={"text": "test", "types": ["test"]}) assert response.status_code == 200 search_results = SearchResponse.model_validate(response.json()) assert len(search_results.results) == 1 # Should not find with wrong entity type - response = await client.post("/search/", json={"text": "test", "entity_types": ["note"]}) + response = await client.post("/search/", json={"text": "test", "types": ["note"]}) assert response.status_code == 200 search_results = SearchResponse.model_validate(response.json()) assert len(search_results.results) == 0 @@ -153,8 +153,8 @@ async def test_multiple_filters(client, indexed_entity): "/search/", json={ "text": "test", - "types": [SearchItemType.ENTITY.value], - "entity_types": ["test"], + "entity_types": [SearchItemType.ENTITY.value], + "types": ["test"], "after_date": datetime(2020, 1, 1, tzinfo=timezone.utc).isoformat(), }, ) diff --git a/tests/mcp/test_tool_read_note.py b/tests/mcp/test_tool_read_note.py index 6e1c5b2a2..9993c825f 100644 --- a/tests/mcp/test_tool_read_note.py +++ b/tests/mcp/test_tool_read_note.py @@ -201,7 +201,8 @@ async def test_read_note_title_search_fallback(mock_call_get, mock_search): # Verify title search was used mock_search.assert_called_once() - assert mock_search.call_args[0][0].title == "Test Note" + assert mock_search.call_args[1]["query"] == "Test Note" + assert mock_search.call_args[1]["search_type"] == "title" # Verify second lookup was used assert mock_call_get.call_count == 2 @@ -254,8 +255,10 @@ async def test_read_note_text_search_fallback(mock_call_get, mock_search): # Verify both search types were used assert mock_search.call_count == 2 - assert mock_search.call_args_list[0][0][0].title == "some query" # Title search - assert mock_search.call_args_list[1][0][0].text == "some query" # Text search + assert mock_search.call_args_list[0][1]["query"] == "some query" # Title search + assert mock_search.call_args_list[0][1]["search_type"] == "title" + assert mock_search.call_args_list[1][1]["query"] == "some query" # Text search + assert mock_search.call_args_list[1][1]["search_type"] == "text" # Verify result contains helpful information assert "Note Not Found" in result diff --git a/tests/mcp/test_tool_resource.py b/tests/mcp/test_tool_resource.py index c1b759909..d8142a0d9 100644 --- a/tests/mcp/test_tool_resource.py +++ b/tests/mcp/test_tool_resource.py @@ -42,6 +42,33 @@ async def test_read_file_text_file(app, synced_files): assert response["encoding"] == "utf-8" +@pytest.mark.asyncio +async def test_read_content_file_path(app, synced_files): + """Test reading a text file. + + Should: + - Correctly identify text content + - Return the content as text + - Include correct metadata + """ + # First create a text file via notes + result = await write_note( + title="Text Resource", + folder="test", + content="This is a test text resource", + tags=["test", "resource"], + ) + assert result is not None + + # Now read it as a resource + response = await read_content("test/Text Resource.md") + + assert response["type"] == "text" + assert "This is a test text resource" in response["text"] + assert response["content_type"].startswith("text/") + assert response["encoding"] == "utf-8" + + @pytest.mark.asyncio async def test_read_file_image_file(app, synced_files): """Test reading an image file. diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index 523a10aa0..f11e07876 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -5,11 +5,10 @@ from basic_memory.mcp.tools import write_note from basic_memory.mcp.tools.search import search_notes -from basic_memory.schemas.search import SearchQuery, SearchItemType @pytest.mark.asyncio -async def test_search_basic(client): +async def test_search_text(client): """Test basic search functionality.""" # Create a test note result = await write_note( @@ -21,8 +20,67 @@ async def test_search_basic(client): assert result # Search for it - query = SearchQuery(text="searchable") - response = await search_notes(query) + response = await search_notes(query="searchable") + + # Verify results + assert len(response.results) > 0 + assert any(r.permalink == "test/test-search-note" for r in response.results) + + +@pytest.mark.asyncio +async def test_search_title(client): + """Test basic search functionality.""" + # Create a test note + result = await write_note( + title="Test Search Note", + folder="test", + content="# Test\nThis is a searchable test note", + tags=["test", "search"], + ) + assert result + + # Search for it + response = await search_notes(query="Search Note", search_type="title") + + # Verify results + assert len(response.results) > 0 + assert any(r.permalink == "test/test-search-note" for r in response.results) + + +@pytest.mark.asyncio +async def test_search_permalink(client): + """Test basic search functionality.""" + # Create a test note + result = await write_note( + title="Test Search Note", + folder="test", + content="# Test\nThis is a searchable test note", + tags=["test", "search"], + ) + assert result + + # Search for it + response = await search_notes(query="test/test-search-note", search_type="permalink") + + # Verify results + assert len(response.results) > 0 + assert any(r.permalink == "test/test-search-note" for r in response.results) + + +@pytest.mark.asyncio +async def test_search_permalink_match(client): + """Test basic search functionality.""" + # Create a test note + result = await write_note( + title="Test Search Note", + folder="test", + content="# Test\nThis is a searchable test note", + tags=["test", "search"], + ) + assert result + + # Search for it + response = await search_notes(query="test/test-search-*", search_type="permalink") # Verify results assert len(response.results) > 0 @@ -42,8 +100,7 @@ async def test_search_pagination(client): assert result # Search for it - query = SearchQuery(text="searchable") - response = await search_notes(query, page=1, page_size=1) + response = await search_notes(query="searchable", page=1, page_size=1) # Verify results assert len(response.results) == 1 @@ -61,8 +118,24 @@ async def test_search_with_type_filter(client): ) # Search with type filter - query = SearchQuery(text="type", types=[SearchItemType.ENTITY]) - response = await search_notes(query) + response = await search_notes(query="type", types=["note"]) + + # Verify all results are entities + assert all(r.type == "entity" for r in response.results) + + +@pytest.mark.asyncio +async def test_search_with_entity_type_filter(client): + """Test search with entity type filter.""" + # Create test content + await write_note( + title="Entity Type Test", + folder="test", + content="# Test\nFiltered by type", + ) + + # Search with entity type filter + response = await search_notes(query="type", entity_types=["entity"]) # Verify all results are entities assert all(r.type == "entity" for r in response.results) @@ -80,8 +153,7 @@ async def test_search_with_date_filter(client): # Search with date filter one_hour_ago = datetime.now() - timedelta(hours=1) - query = SearchQuery(text="recent", after_date=one_hour_ago) - response = await search_notes(query) + response = await search_notes(query="recent", after_date=one_hour_ago.isoformat()) # Verify we get results within timeframe assert len(response.results) > 0 diff --git a/tests/schemas/test_search.py b/tests/schemas/test_search.py index ad86db31a..f3c4cecd6 100644 --- a/tests/schemas/test_search.py +++ b/tests/schemas/test_search.py @@ -32,12 +32,12 @@ def test_search_filters(): """Test search result filtering.""" query = SearchQuery( text="search", - types=[SearchItemType.ENTITY], - entity_types=["component"], + entity_types=[SearchItemType.ENTITY], + types=["component"], after_date=datetime(2024, 1, 1), ) - assert query.types == [SearchItemType.ENTITY] - assert query.entity_types == ["component"] + assert query.entity_types == [SearchItemType.ENTITY] + assert query.types == ["component"] assert query.after_date == "2024-01-01T00:00:00" diff --git a/tests/services/test_search_service.py b/tests/services/test_search_service.py index 6e721f1d5..1c3d1811b 100644 --- a/tests/services/test_search_service.py +++ b/tests/services/test_search_service.py @@ -75,7 +75,7 @@ async def test_search_permalink_wildcard2(search_service, test_graph): async def test_search_text(search_service, test_graph): """Full-text search""" results = await search_service.search( - SearchQuery(text="Root Entity", types=[SearchItemType.ENTITY]) + SearchQuery(text="Root Entity", entity_types=[SearchItemType.ENTITY]) ) assert len(results) >= 1 assert results[0].permalink == "test/root" @@ -84,7 +84,9 @@ async def test_search_text(search_service, test_graph): @pytest.mark.asyncio async def test_search_title(search_service, test_graph): """Title only search""" - results = await search_service.search(SearchQuery(title="Root", types=[SearchItemType.ENTITY])) + results = await search_service.search( + SearchQuery(title="Root", entity_types=[SearchItemType.ENTITY]) + ) assert len(results) >= 1 assert results[0].permalink == "test/root" @@ -140,7 +142,7 @@ async def test_filters(search_service, test_graph): """Test search filters.""" # Combined filters results = await search_service.search( - SearchQuery(text="Deep", types=[SearchItemType.ENTITY], entity_types=["deep"]) + SearchQuery(text="Deep", entity_types=[SearchItemType.ENTITY], types=["deep"]) ) assert len(results) == 1 for r in results: @@ -179,13 +181,18 @@ async def test_search_type(search_service, test_graph): """Test search filters.""" # Should find only type - results = await search_service.search(SearchQuery(types=[SearchItemType.ENTITY])) + results = await search_service.search(SearchQuery(types=["test"])) assert len(results) > 0 for r in results: assert r.type == SearchItemType.ENTITY - # Should find only types passed in - results = await search_service.search(SearchQuery(types=[SearchItemType.ENTITY])) + +@pytest.mark.asyncio +async def test_search_entity_type(search_service, test_graph): + """Test search filters.""" + + # Should find only type + results = await search_service.search(SearchQuery(entity_types=[SearchItemType.ENTITY])) assert len(results) > 0 for r in results: assert r.type == SearchItemType.ENTITY