@@ -155,7 +155,7 @@ def _format_search_error_response(
155155 - Boolean NOT: `project NOT archived`
156156 - Grouped: `(project OR planning) AND notes`
157157 - Exact phrases: `"weekly standup meeting"`
158- - Content-specific: `tag:example` or `category:observation`
158+ - Content-specific: `tag:example`
159159
160160 ## Try again with:
161161 ```
@@ -222,7 +222,7 @@ def _format_search_error_response(
222222
223223 6. **Try advanced search patterns**:
224224 - Tag search: `search_notes("{ project } ","tag:your-tag")`
225- - Category search : `search_notes("{ project } ","category: observation")`
225+ - Observation category : `search_notes("{ project } ","{ query } ", entity_types=[" observation"], categories=["requirement"] )`
226226 - Pattern matching: `search_notes("{ project } ","*{ query } *", search_type="permalink")`
227227
228228 ## Explore what content exists:
@@ -299,7 +299,8 @@ def _format_search_error_response(
299299- **Boolean**: `term1 AND term2`, `term1 OR term2`, `term1 NOT term2`
300300- **Phrases**: `"exact phrase"`
301301- **Grouping**: `(term1 OR term2) AND term3`
302- - **Patterns**: `tag:example`, `category:observation`"""
302+ - **Tags**: `tag:example`
303+ - **Observation categories**: `entity_types=["observation"], categories=["requirement"]`"""
303304
304305
305306def _format_search_markdown (result : SearchResponse , project : str , query : str | None ) -> str :
@@ -497,6 +498,7 @@ async def _search_all_projects(
497498 output_format : Literal ["text" , "json" ],
498499 note_types : list [str ],
499500 entity_types : list [str ],
501+ categories : list [str ],
500502 after_date : str | None ,
501503 metadata_filters : dict [str , Any ] | None ,
502504 tags : list [str ] | None ,
@@ -555,6 +557,7 @@ async def _search_all_projects(
555557 output_format = "json" ,
556558 note_types = note_types or None ,
557559 entity_types = entity_types or None ,
560+ categories = categories or None ,
558561 after_date = after_date ,
559562 metadata_filters = metadata_filters ,
560563 tags = tags ,
@@ -653,6 +656,14 @@ async def search_notes(
653656 "'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
654657 "'Chapter' here — use note_types instead." ,
655658 ] = None ,
659+ categories : Annotated [
660+ List [str ] | None ,
661+ BeforeValidator (coerce_list ),
662+ Field (default = None , validation_alias = AliasChoices ("categories" , "category" )),
663+ "Filter observation results to these exact categories (e.g. ['requirement']). "
664+ "Pair with entity_types=['observation'] to return only observations whose "
665+ "category matches exactly — not every row mentioning the word." ,
666+ ] = None ,
656667 # Time-filter naming varies wildly across APIs.
657668 after_date : Annotated [
658669 Optional [str ],
@@ -707,7 +718,8 @@ async def search_notes(
707718
708719 ### Content-Specific Searches
709720 - `search_notes("research", "tag:example")` - Search within specific tags (if supported by content)
710- - `search_notes("work-project", "category:observation")` - Filter by observation categories
721+ - `search_notes("work-project", "req", entity_types=["observation"], categories=["requirement"])`
722+ - Return only observations whose category is exactly "requirement"
711723 - `search_notes("team-docs", "author:username")` - Find content by author (if metadata available)
712724
713725 **Note:** `tag:` shorthand is automatically converted to a `tags` filter, so it works
@@ -725,6 +737,8 @@ async def search_notes(
725737 - `search_notes("my-project", "query", note_types=["note"])` - Search only notes
726738 - `search_notes("work-docs", "query", note_types=["note", "person"])` - Multiple note types
727739 - `search_notes("research", "query", entity_types=["observation"])` - Filter by entity type
740+ - `search_notes("research", "query", entity_types=["observation"], categories=["requirement"])`
741+ - Filter observations to an exact category
728742 - `search_notes("team-docs", "query", after_date="2024-01-01")` - Recent content only
729743 - `search_notes("my-project", "query", after_date="1 week")` - Relative date filtering
730744 - `search_notes("my-project", "query", tags=["security"])` - Filter by frontmatter tags
@@ -776,6 +790,9 @@ async def search_notes(
776790 "json" returns a machine-readable dictionary payload.
777791 note_types: Optional list of note types to search (e.g., ["note", "person"])
778792 entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
793+ categories: Optional list of observation categories for exact matching (e.g.,
794+ ["requirement"]). Pair with entity_types=["observation"] to return only
795+ observations whose category matches exactly.
779796 after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
780797 metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"})
781798 tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]
@@ -853,6 +870,9 @@ async def search_notes(
853870 # Lowercase note_types so "Chapter" matches the stored "chapter".
854871 note_types = [t .lower () for t in note_types ] if note_types else []
855872 entity_types = entity_types or []
873+ # Categories are matched exactly against the indexed observation category,
874+ # so preserve their original casing (unlike the lowercased note_types).
875+ categories = categories or []
856876
857877 # Parse tag:<value> shorthand at tool level so it works with all search modes.
858878 # Handles "tag:security", "tag:coffee tag:brewing", "tag:coffee AND tag:brewing".
@@ -894,6 +914,7 @@ async def search_notes(
894914 output_format = output_format ,
895915 note_types = note_types ,
896916 entity_types = entity_types ,
917+ categories = categories ,
897918 after_date = after_date ,
898919 metadata_filters = metadata_filters ,
899920 tags = tags ,
@@ -917,8 +938,15 @@ async def search_notes(
917938 has_query = bool (query and query .strip ()),
918939 note_type_filter_count = len (note_types ),
919940 entity_type_filter_count = len (entity_types ),
941+ category_filter_count = len (categories ),
920942 has_filters = bool (
921- metadata_filters or tags or status or note_types or entity_types or after_date
943+ metadata_filters
944+ or tags
945+ or status
946+ or note_types
947+ or entity_types
948+ or categories
949+ or after_date
922950 ),
923951 has_tags_filter = bool (tags ),
924952 has_status_filter = bool (status ),
@@ -981,6 +1009,8 @@ async def search_notes(
9811009 # Add optional filters if provided (empty lists are treated as no filter)
9821010 if entity_types :
9831011 search_query .entity_types = [SearchItemType (t ) for t in entity_types ]
1012+ if categories :
1013+ search_query .categories = categories
9841014 if note_types :
9851015 search_query .note_types = note_types
9861016 if after_date :
@@ -1006,15 +1036,26 @@ async def search_notes(
10061036 return (
10071037 "# No Search Criteria\n \n "
10081038 "Please provide at least one of: `query`, `metadata_filters`, "
1009- "`tags`, `status`, `note_types`, `entity_types`, or `after_date`."
1039+ "`tags`, `status`, `note_types`, `entity_types`, `categories`, "
1040+ "or `after_date`."
10101041 )
10111042
10121043 # Default to entity-level results to avoid returning individual
10131044 # observations/relations as separate search results (see issue #31).
10141045 # Applied after no_criteria() so that the implicit default doesn't
10151046 # mask a truly empty search request.
10161047 if not search_query .entity_types :
1017- search_query .entity_types = [SearchItemType ("entity" )]
1048+ # Trigger: a category filter was supplied without an explicit
1049+ # entity_types.
1050+ # Why: categories only exist on observations — defaulting to "entity"
1051+ # (whose rows have NULL category) would AND a category filter against
1052+ # entity rows and return nothing, defeating a category-only search.
1053+ # Outcome: scope the implicit default to observations so
1054+ # search_notes(categories=[...]) returns the matching bullets.
1055+ if search_query .categories :
1056+ search_query .entity_types = [SearchItemType ("observation" )]
1057+ else :
1058+ search_query .entity_types = [SearchItemType ("entity" )]
10181059
10191060 logger .debug (
10201061 f"Search request: project={ active_project .name } "
0 commit comments