feat: Support tag: query shorthand in search by phernandez · Pull Request #535 · basicmachines-co/basic-memory · GitHub
Skip to content

feat: Support tag: query shorthand in search - #535

Merged
phernandez merged 4 commits into
mainfrom
fix/tag-search-shorthand
Feb 3, 2026
Merged

feat: Support tag: query shorthand in search#535
phernandez merged 4 commits into
mainfrom
fix/tag-search-shorthand

Conversation

@phernandez

Copy link
Copy Markdown
Member

Summary

  • map tag:<value> query prefix to tags metadata filter so tag searches work again
  • split multiple tags by commas/whitespace and drop text search when tag filter is applied
  • add positive and negative tests covering tag shorthand and multi-tag behavior across backends

Testing

  • uv run pytest tests/services/test_search_service.py -k "tag_prefix" -q

Signed-off-by: phernandez <paul@basicmachines.co>
@github-actions

github-actions Bot commented Feb 1, 2026

Copy link
Copy Markdown
Contributor

Signed-off-by: phernandez <paul@basicmachines.co>
@github-actions

github-actions Bot commented Feb 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @phernandez's task in 2m 18s —— View job


Code Review: Support tag: query shorthand in search

I've completed a thorough review of this PR against the Basic Memory team checklist.


Summary

This PR implements tag:<value> query shorthand for tag filtering in search. The implementation is functionally sound with good test coverage for the happy path. However, it needs improvements to meet the project's literate programming standards and has a few design questions that should be addressed.

Files changed:

  • search_service.py:84-91 (+11 lines) - Core tag: prefix parsing
  • postgres_search_repository.py:335 (+1/-1 line) - Postgres CAST syntax fix
  • test_search_service.py (+75/-9 lines) - Test coverage

Team Checklist Results

✅ Code Quality & Standards

  • ✅ Python 3.12+ type annotations maintained
  • ✅ 100-character line length limit
  • ✅ Follows async patterns
  • ✅ Proper naming conventions
  • ✅ No commented-out code
  • ✅ Import order correct (added re module)

❌ Missing: Literate programming comments

The new code at search_service.py:84-91 lacks decision point comments required by CLAUDE.md. Per the guidelines, conditionals that materially change behavior need:

  • Trigger: what condition causes this branch
  • Why: the rationale
  • Outcome: what changes downstream

Example of what's needed:

# Support tag:<tag> shorthand by mapping to tags filter
if query.text:
    text = query.text.strip()
    # Trigger: query starts with "tag:" prefix (case-insensitive)
    # Why: provide convenient shorthand for tag filtering without verbose metadata syntax
    # Outcome: transforms to tags filter, drops text search to avoid empty results
    if text.lower().startswith("tag:"):
        # Split on both commas and whitespace to support: tag:tier1,alpha or tag:tier1 alpha
        tag_values = re.split(r"[,\s]+", text[4:].strip())
        tags = [t for t in tag_values if t]
        if tags:
            query.tags = tags
            # Outcome: text search is removed since tag prefix consumed entire query
            query.text = None

Add literate programming comments →


⚠️ Testing & Documentation

Testing:

  • ✅ Unit tests added (3 tests covering positive, negative, multi-tag cases)
  • ✅ Tests use descriptive names and docstrings
  • ✅ Test coverage appears maintained at 100%
  • ⚠️ Missing edge case tests:
    • Empty tag value: tag: (what happens?)
    • Uppercase prefix: TAG:tier1 (works due to .lower() but not verified)
    • Mixed delimiters: tag:tier1, alpha beta (regex handles but not tested)
    • Special characters in tags: tag:my-tag_v2.1 (common in real use)
    • Whitespace variations: tag: tier1, tag:tier1 (strip handles but not tested)

Documentation:

  • Missing: User-facing documentation - The tag: syntax isn't documented anywhere users would find it:
    • Not in MCP tool docstrings (where search is exposed to LLMs)
    • Not in CLAUDE.md MCP capabilities section
    • Not in service-level docstring for SearchService.search()

Suggestions:

  1. Add to SearchService.search() docstring:

    """Search across all indexed content.
    
    Supports three modes:
    1. Exact permalink: finds direct matches for a specific path
    2. Pattern match: handles * wildcards in paths
    3. Text search: full-text search across title/content
       - Supports tag: shorthand (e.g., "tag:tier1" or "tag:tier1,alpha")
  2. Update CLAUDE.md's "Search & Discovery" section to mention tag: syntax

Add edge case tests →


✅ Basic Memory Architecture

  • ✅ Uses existing metadata filter infrastructure (no schema changes)
  • ✅ Preserves local-first architecture
  • ✅ Knowledge graph consistency maintained
  • ✅ Follows service layer pattern
  • ✅ Postgres fix (CAST syntax) maintains cross-DB compatibility

✅ Security & Performance

  • ✅ Input validation via safe regex (no eval/exec)
  • ✅ No hardcoded secrets
  • ✅ Error handling inherited from existing search
  • ✅ No SQL injection risk (uses existing parameterized queries)
  • ⚠️ Minor regex overhead on every text query (likely negligible but runs on every search)

✅ Compatibility

  • ✅ Windows path compatibility not applicable
  • ✅ No emoji/unicode in console output
  • ✅ Cross-database support maintained (works on both SQLite and Postgres)

Detailed Issues & Questions

1. Input Mutation (search_service.py:90-91)

The code mutates the input query object:

query.tags = tags
query.text = None

Issue: If the caller reuses the SearchQuery object, they'll get unexpected behavior on subsequent calls.

Question: Is this mutation intentional? SearchQuery is a Pydantic schema - mutating input objects can lead to surprising behavior.

Recommendation: Either:

  • Document this mutation behavior clearly in comments/docstring
  • OR create a new query object (safer): query = query.model_copy(update={'tags': tags, 'text': None})

2. Text Search Dropped (search_service.py:91)

Setting query.text = None completely removes text search when tag filter is applied.

Questions:

  • Why is text search dropped? The PR description states this happens but doesn't explain the rationale
  • Should combined queries work? e.g., tag:tier1 important to find tagged entities also containing "important"
  • This design decision should be documented in code comments

Current behavior:

  • tag:tier1 important → searches for tags ["tier1", "important"] (treats "important" as a tag, not search text)

Possible alternative behavior:

  • tag:tier1 important → searches for tags=["tier1"] AND text="important"

Recommendation: Add a comment explaining why text is dropped and whether this is the desired long-term behavior.


3. Delimiter Ambiguity (search_service.py:87)

The regex r"[,\s]+" treats both commas AND whitespace as tag separators:

  • tag:tier1,alpha["tier1", "alpha"]
  • tag:tier1 alpha["tier1", "alpha"]
  • tag:tier1, alpha["tier1", "alpha"]

Questions:

  • Is whitespace as a separator intentional or just a side effect?
  • Should tag:tier1 alpha be one tag with space ("tier1 alpha") or two tags?
  • If intentional, should be documented

Recommendation: Add comment documenting delimiter behavior:

# Split on both commas and whitespace: "tag:tier1,alpha" or "tag:tier1 alpha" both work
tag_values = re.split(r"[,\s]+", text[4:].strip())

4. Case Sensitivity (search_service.py:86)

The prefix check is case-insensitive (text.lower().startswith("tag:")):

  • tag:tier1
  • TAG:tier1
  • Tag:tier1

Questions:

  • Are tag values case-sensitive?
  • Does tag:Tier1 match an entity with tier1 in frontmatter?
  • Should tag values be normalized to lowercase?

Recommendation: Add test verifying case handling for both prefix and tag values.


5. Test Assertions Could Be Stronger

Test at line 534 verifies results but doesn't verify the query transformation:

results = await search_service.search(SearchQuery(text="tag:tier1"))
assert any(r.permalink == tagged_entity.permalink for r in results)

Suggestion: Also assert that the transformation actually occurred:

query = SearchQuery(text="tag:tier1")
results = await search_service.search(query)
assert query.tags == ["tier1"], "tag: prefix should set tags filter"
assert query.text is None, "tag: prefix should clear text search"
assert any(r.permalink == tagged_entity.permalink for r in results)

6. Postgres CAST Fix (postgres_search_repository.py:335)

Changed :{tag_param}::jsonb to CAST(:{tag_param} AS jsonb).

Question: Was the :: syntax causing issues with SQLAlchemy parameter binding? This is a good fix for compatibility, but understanding why is helpful.

Note: The CAST() syntax is ANSI SQL standard and more portable than Postgres-specific :: casting.


Recommendations

🔴 High Priority (Should address before merge)

  1. Add decision point comments per CLAUDE.md literate programming style
  2. Document the feature - add to search service docstring and CLAUDE.md
  3. Explain text search behavior - add comment explaining why query.text = None
  4. Document delimiter behavior - clarify that both , and whitespace work as separators

🟡 Medium Priority (Recommended)

  1. Add edge case tests - empty values, uppercase, special chars, mixed delimiters
  2. Clarify case sensitivity - document or test tag value case handling
  3. Consider query mutation - document or avoid mutating input object
  4. Verify test coverage - run just coverage to confirm 100% maintained

🟢 Low Priority (Nice to have)

  1. Test assertion improvements - verify query transformation, not just results
  2. Consider combined queries - decide if tag:tier1 searchterm should work as tag filter + text search

Final Assessment

Functionality: ✅ Works as described
Code Quality: ⚠️ Missing literate programming comments (CLAUDE.md requirement)
Testing: ⚠️ Happy path covered, edge cases missing
Documentation: ❌ Feature not documented for users
Architecture: ✅ Follows project patterns
Security: ✅ No issues identified
Performance: ✅ Negligible impact

Recommendation: The core implementation is solid and functional. To meet Basic Memory's standards, address the high-priority items (literate programming comments and documentation) before merge. The medium-priority items would strengthen the implementation but aren't blocking.

The implementation follows existing patterns well and the Postgres fix is a good catch. With the additions above, this will be a clean, well-documented feature.


| Branch: fix/tag-search-shorthand

@github-actions

github-actions Bot commented Feb 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @phernandez's task in 2m 35s —— View job


Code Review: Support tag: query shorthand in search

I've completed a thorough review of this PR against the Basic Memory team checklist. The implementation is functionally correct and has good test coverage, but there are some important issues that need to be addressed before merging.


Summary

This PR implements tag:<value> query shorthand that maps to the tags metadata filter. The core functionality works well, but the implementation violates Basic Memory's literate programming standards by lacking decision point comments, and has a design issue with input mutation.

Files changed:

  • search_service.py:84-91 (+11 lines) - Tag prefix parsing logic
  • test_search_service.py (+75/-9 lines) - Test coverage for tag shorthand

Team Checklist Results

✅ Code Quality & Standards

  • ✅ Python 3.12+ type annotations maintained
  • ✅ 100-character line length respected
  • ✅ Async patterns followed
  • ✅ Proper naming conventions (snake_case)
  • ✅ No commented-out code
  • ✅ Import order correct (re module added appropriately)

❌ CRITICAL: Missing literate programming comments

Per CLAUDE.md lines 87-91, conditionals that materially change behavior require decision point comments with Trigger/Why/Outcome. The code at search_service.py:84-91 completely lacks these required comments.

What's needed:

# Support tag:<tag> shorthand by mapping to tags filter
if query.text:
    text = query.text.strip()
    # Trigger: query starts with "tag:" prefix (case-insensitive)
    # Why: provide convenient shorthand for tag filtering without verbose metadata syntax
    # Outcome: transforms to tags filter, drops text search to avoid empty results
    if text.lower().startswith("tag:"):
        # Split on both commas and whitespace: "tag:tier1,alpha" or "tag:tier1 alpha"
        tag_values = re.split(r"[,\s]+", text[4:].strip())
        tags = [t for t in tag_values if t]
        if tags:
            query.tags = tags
            # Outcome: text search removed since tag prefix consumed entire query
            query.text = None

Add literate comments →


⚠️ Testing & Documentation

Testing:

  • ✅ Unit tests added (3 tests covering positive, negative, multi-tag cases)
  • ✅ Tests have descriptive names and docstrings
  • ✅ Test coverage appears maintained
  • ⚠️ Missing edge case tests:
    • Empty tag value: tag: (what should happen?)
    • Uppercase prefix: TAG:tier1 (works but not tested)
    • Mixed delimiters: tag:tier1, alpha beta (whitespace handling)
    • Special characters: tag:my-tag_v2.1 (common in real tags)
    • Leading/trailing whitespace: tag: tier1, tag:tier1

Documentation:

  • Missing: User-facing documentation - The tag: syntax isn't documented:
    • Not in SearchService.search() docstring (lines 76-82)
    • Not in CLAUDE.md's MCP capabilities section
    • Not in any MCP tool docstrings where search is exposed

Recommendation: Update the docstring at search_service.py:76-82:

"""Search across all indexed content.

Supports three modes:
1. Exact permalink: finds direct matches for a specific path
2. Pattern match: handles * wildcards in paths
3. Text search: full-text search across title/content
   - Supports tag: shorthand (e.g., "tag:tier1" or "tag:tier1,alpha")
"""

Add edge case tests →


✅ Basic Memory Architecture

  • ✅ Uses existing metadata filter infrastructure (no schema changes)
  • ✅ Preserves local-first architecture
  • ✅ Knowledge graph consistency maintained
  • ✅ Follows service layer pattern correctly
  • ✅ No database migrations required

⚠️ Security & Performance

  • ✅ Input validation via safe regex (no eval/exec)
  • ✅ No hardcoded secrets
  • ✅ Error handling inherited from existing search
  • ✅ No SQL injection risk (parameterized queries)
  • ⚠️ Minor regex overhead on every text query (negligible but runs on all searches)

✅ Compatibility

  • ✅ Windows path compatibility N/A
  • ✅ No emoji/unicode in console output
  • ✅ Cross-database support (SQLite and Postgres)

Critical Issues

1. Input Mutation Anti-Pattern (search_service.py:90-91)

Issue: The code mutates the input SearchQuery object:

query.tags = tags
query.text = None

Problem: Pydantic models are meant to be immutable. If callers reuse the SearchQuery object, they'll get unexpected behavior:

# Caller code
my_query = SearchQuery(text="tag:tier1")
results1 = await search_service.search(my_query)  # Works, mutates my_query
results2 = await search_service.search(my_query)  # Broken! my_query now has tags=["tier1"], text=None

Recommendation: Either:

  1. Document the mutation clearly in the method docstring, OR
  2. Create a new query object (safer):
    query = query.model_copy(update={'tags': tags, 'text': None})

This follows Pydantic best practices and the principle of least surprise.


2. Design Decision Not Explained (search_service.py:91)

Issue: Setting query.text = None completely removes text search when tag filter is applied.

Questions:

  • Why is text search dropped? The PR description mentions this but doesn't explain the rationale
  • Is this the desired long-term behavior?
  • Should combined queries work? e.g., tag:tier1 important to find tagged entities also containing "important"

Current behavior:

  • tag:tier1 important → searches for tags ["tier1", "important"] (treats "important" as a second tag, not search text)

Possible alternative:

  • tag:tier1 important → searches for tags=["tier1"] AND text="important"

Recommendation: Add a comment explaining this design decision and whether it's intentional or a simplification for v1.


3. Delimiter Ambiguity (search_service.py:87)

Issue: The regex r"[,\s]+" treats both commas AND whitespace as tag separators:

  • tag:tier1,alpha["tier1", "alpha"]
  • tag:tier1 alpha["tier1", "alpha"]
  • tag:tier1, alpha["tier1", "alpha"]

Question: Is whitespace as a delimiter intentional? Should tag:tier1 alpha be:

  • Two tags: ["tier1", "alpha"] (current), OR
  • One tag with space: ["tier1 alpha"]?

Recommendation: Document the delimiter behavior in a comment:

# Split on both commas and whitespace: "tag:tier1,alpha" or "tag:tier1 alpha" both work
tag_values = re.split(r"[,\s]+", text[4:].strip())

4. Case Sensitivity Unclear (search_service.py:86)

Observation: The prefix check is case-insensitive:

  • tag:tier1
  • TAG:tier1 ✅ (due to .lower())
  • Tag:tier1

Questions:

  • Are tag values case-sensitive?
  • Does tag:Tier1 match an entity with frontmatter tags: [tier1]?
  • Should tag values be normalized to lowercase?

Recommendation: Add test verifying case handling for both prefix and tag values.


5. Test Assertions Could Be Stronger

Issue: Tests verify results but don't verify the query transformation (e.g., line 534):

results = await search_service.search(SearchQuery(text="tag:tier1"))
assert any(r.permalink == tagged_entity.permalink for r in results)

Suggestion: Also assert the transformation:

query = SearchQuery(text="tag:tier1")
results = await search_service.search(query)
assert query.tags == ["tier1"], "tag: prefix should set tags filter"
assert query.text is None, "tag: prefix should clear text search"
assert any(r.permalink == tagged_entity.permalink for r in results)

This verifies the how not just the what.


Recommendations

🔴 High Priority (Must address before merge)

  1. Add decision point comments per CLAUDE.md literate programming guidelines (lines 87-100)
  2. Fix or document input mutation - either avoid mutating input or document it clearly
  3. Explain text search behavior - add comment explaining why query.text = None
  4. Document the feature - add to SearchService.search() docstring

🟡 Medium Priority (Strongly recommended)

  1. Add edge case tests - empty values, uppercase, special chars, whitespace
  2. Clarify delimiter behavior - document that both , and whitespace work
  3. Clarify case sensitivity - test or document tag value case handling
  4. Consider combined queries - decide if tag:tier1 searchterm should support text search too

🟢 Low Priority (Nice to have)

  1. Strengthen test assertions - verify transformation, not just results
  2. Update CLAUDE.md - add tag: syntax to MCP capabilities documentation

Final Assessment

Aspect Status Notes
Functionality Works as described
Code Quality Missing literate programming comments (CLAUDE.md requirement)
Testing ⚠️ Happy path covered, edge cases missing
Documentation Feature not documented for users
Architecture Follows project patterns
Security No issues identified
Performance Negligible impact

Recommendation: The implementation is functionally solid, but does not meet Basic Memory's coding standards due to missing literate programming comments and undocumented input mutation. Address the 🔴 high-priority items before merge. The 🟡 medium-priority items would significantly strengthen the implementation.

The core functionality works well and the tests cover the happy path adequately. With proper documentation and comments, this will be a clean, maintainable feature.

--- | Branch: fix/tag-search-shorthand

@phernandez phernandez changed the title Support tag: query shorthand in search feat: Support tag: query shorthand in search Feb 2, 2026
@phernandez
phernandez merged commit f1d50c2 into main Feb 3, 2026
28 of 29 checks passed
@phernandez
phernandez deleted the fix/tag-search-shorthand branch February 3, 2026 01:22
@phernandez phernandez added this to the v0.19.0 milestone Feb 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant