Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/metadata-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Basic Memory automatically indexes custom frontmatter fields so you can query th

Filters are a JSON dictionary where each key targets a frontmatter field and the value specifies the match condition. Multiple keys combine with **AND** logic — every filter must match.

Metadata filters only ever match Markdown notes. A project can also index PDFs, images and other regular files, but those carry no frontmatter, so they are never metadata hits and never counted in the result total — including for the null filter below, which asks about a *missing* value.

### Equality

Match a single value exactly.
Expand Down Expand Up @@ -55,6 +57,23 @@ Range filter (inclusive). Takes a `[min, max]` pair.
{"score": {"$between": [0.3, 0.8]}}
```

### Null (missing or explicitly null)

A `None` value asks whether a note carries any value for the field at all.

```json
{"owner": null}
```

Matches notes whose frontmatter has no `owner` key, and notes whose `owner` is
explicitly null. Both backends extract those two cases to SQL `NULL`, so the
filter answers the same note set on SQLite and Postgres.

Null works only through equality. Inside `$in`, `$between`, an array-contains
list, or a comparison it is rejected: those compile to a SQL comparison against
the value, and a comparison with `NULL` is never true, so the filter would
report a confident zero rather than the query it cannot express.

### Nested Access (dot notation)

Access nested frontmatter values using dots.
Expand All @@ -70,6 +89,7 @@ This queries the `version` key inside a `schema` object in frontmatter.
| Operator | Syntax | Example |
|----------|--------|---------|
| Equality | `{"field": "value"}` | `{"status": "active"}` |
| Is null | `{"field": null}` | `{"owner": null}` |
| Array contains (all) | `{"field": ["a", "b"]}` | `{"tags": ["security", "oauth"]}` |
| `$in` (any of) | `{"field": {"$in": [...]}}` | `{"priority": {"$in": ["high", "critical"]}}` |
| `$gt` / `$gte` | `{"field": {"$gt": N}}` | `{"confidence": {"$gt": 0.7}}` |
Expand All @@ -82,6 +102,8 @@ This queries the `version` key inside a `schema` object in frontmatter.
- Each operator dict must contain exactly one operator.
- `$in` and array-contains require non-empty lists.
- `$between` requires exactly two values `[min, max]`.
- `null` is an is-null match and only valid as a plain equality value.
- Comparison and `$between` bounds must be finite numbers. A magnitude no float can hold — a 400-digit integer, which JSON keeps as an ordinary `int` — is refused as a filter error rather than compared against an infinite bound.

## MCP Tool — `search_notes`

Expand Down
245 changes: 228 additions & 17 deletions skills/memory-literary-analysis/SKILL.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions skills/memory-metadata-search/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ Numeric values use numeric comparison; strings use lexicographic comparison.
{"score": {"$between": [0.3, 0.8]}}
```

### Null (field missing or explicitly null)

```json
{"owner": null}
```

Matches notes with no `owner` key and notes whose `owner` is explicitly null.
Null works only as a plain equality value — inside `$in`, `$between`, an
array-contains list, or a comparison it is rejected, because those compare
against the value and a comparison with null is never true.

### Nested Access (dot notation)

```json
Expand All @@ -67,6 +78,7 @@ Numeric values use numeric comparison; strings use lexicographic comparison.
| Operator | Syntax | Example |
|----------|--------|---------|
| Equality | `{"field": "value"}` | `{"status": "active"}` |
| Is null | `{"field": null}` | `{"owner": null}` |
| Array contains | `{"field": ["a", "b"]}` | `{"tags": ["security", "oauth"]}` |
| `$in` | `{"field": {"$in": [...]}}` | `{"priority": {"$in": ["high", "critical"]}}` |
| `$gt` / `$gte` | `{"field": {"$gt": N}}` | `{"confidence": {"$gt": 0.7}}` |
Expand All @@ -79,6 +91,12 @@ Numeric values use numeric comparison; strings use lexicographic comparison.
- Operator dicts must contain exactly one operator
- `$in` and array-contains require non-empty lists
- `$between` requires exactly `[min, max]`
- `null` is an is-null match and only valid as a plain equality value
- Comparison and `$between` bounds must be finite numbers — a magnitude no float
can hold (a 400-digit integer, which JSON keeps as an ordinary `int`) is
refused rather than compared against an infinite bound
- Metadata filters match Markdown notes only — indexed PDFs, images and other
regular files carry no frontmatter and are never hits, not even for `null`

> **Warning:** Operators MUST include the `$` prefix — write `$gte`, not `gte`. Without the prefix the filter is treated as an exact-match key and will silently return no results. Correct: `{"confidence": {"$gte": 0.7}}`. Wrong: `{"confidence": {"gte": 0.7}}`.

Expand Down
6 changes: 5 additions & 1 deletion src/basic_memory/api/v2/routers/search_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,11 @@ async def search(
or query.permalink_match
),
has_filters=bool(
query.note_types or query.entity_types or query.categories or query.metadata_filters
query.note_types
or query.entity_types
or query.categories
or query.metadata_filters
or query.file_path_prefix
),
):
cache_key = ReadCacheKey(
Expand Down
101 changes: 100 additions & 1 deletion src/basic_memory/cli/commands/posix.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
on ``--json`` or when piped, undecorated text with ``--plain``.
"""

import json
import sys
from dataclasses import dataclass, field
from typing import Annotated, Any, Optional
Expand Down Expand Up @@ -265,6 +266,63 @@ def _plain_find(result: dict[str, Any]) -> None:
print(str(node.get("file_path") or node.get("directory_path") or ""))


# --- find --meta --fields rendering ---
# Metadata predicates flip find's payload to the search response shape; without
# --fields the shared search renderers (grep's) apply, with --fields these two
# small renderers add the projected columns so the shared ones stay untouched.


def _search_page_summary(result: dict[str, Any]) -> str:
"""Describe a search results page without inventing a final page."""
summary = f"page {result.get('current_page', 1)} • total {result.get('total', 0)}"
if result.get("has_more") is True:
summary += " • more available (--page)"
return summary


def _field_cell(value: Any) -> str:
"""Render one projected field value: strings bare, null empty, the rest compact JSON."""
if value is None:
return ""
if isinstance(value, str):
return value
return json.dumps(value, separators=(",", ":"))


def _display_find_fields(
result: dict[str, Any], path: str, meta: list[str], fields: list[str]
) -> None:
"""Render metadata hits with their projected fields as a Rich table."""
rows: list[dict[str, Any]] = list(result.get("results", []))
title = (
f"find [bold cyan]{markup_escape(path)}[/bold cyan]"
f" [dim]--meta {markup_escape(' AND '.join(meta))}[/dim]"
)
subtitle = _search_page_summary(result)

if not rows:
console.print(Panel(Text("No matches.", style="dim"), title=title, subtitle=subtitle))
return

table = Table(show_header=True, header_style="bold", expand=False)
table.add_column("Path", style="bold cyan")
for field_name in fields:
table.add_column(markup_escape(field_name))
for row in rows:
projected = row.get("fields") or {}
cells = [markup_escape(str(row.get("file_path") or ""))]
cells.extend(markup_escape(_field_cell(projected.get(field_name))) for field_name in fields)
table.add_row(*cells)
console.print(Panel(table, title=title, subtitle=subtitle, expand=False))


def _plain_find_fields(result: dict[str, Any]) -> None:
"""Render metadata hits as file-path<TAB>compact-JSON-fields lines."""
for row in result.get("results", []):
fields_json = json.dumps(row.get("fields") or {}, separators=(",", ":"))
print(f"{row.get('file_path', '')}\t{fields_json}")


# --- tail rendering ---
# tail's row shape ({type, title, permalink, file_path, created_at}) differs
# from recent-activity's payload, so it gets its own small renderers rather
Expand Down Expand Up @@ -681,20 +739,43 @@ def find(
page_size: Annotated[
int, typer.Option("--page-size", help="Nodes per page")
] = DEFAULT_DIRECTORY_PAGE_SIZE,
meta: Annotated[
Optional[list[str]],
typer.Option(
"--meta",
help=(
"Metadata predicate, repeatable: 'status=active', 'confidence>0.6', "
"'priority in high,critical', 'tags has security', 'score between 0.3,0.8', "
"'owner=null' (key missing or null). "
"PATH still scopes the query, by file path"
),
),
] = None,
fields: Annotated[
Optional[str],
typer.Option(
"--fields",
help=(
'Comma-separated frontmatter fields to show per hit, e.g. "title,priority" '
"(requires --meta)"
),
),
] = None,
json_output: JsonOption = False,
plain: PlainOption = False,
project: ProjectOption = None,
project_id: ProjectIdOption = None,
local: LocalOption = False,
cloud: CloudOption = False,
) -> None:
"""Recursively list files under a directory, optionally filtered by name glob.
"""Recursively list files by name glob, or query notes by frontmatter metadata.

Examples:

bm find --name "*.md"
bm find /specs --depth 3
bm find /notes --name "auth*" --plain
bm find /specs --meta "status=active" --meta "confidence>0.6" --fields title,priority
"""
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
from basic_memory.mcp.tools import find as mcp_find
Expand All @@ -703,6 +784,9 @@ def find(
try:
validate_routing_flags(local, cloud)
_validate_output_flags(json_output, plain)
# The CLI only splits the comma form; validation (non-empty names,
# requires --meta) lives in the shared tool layer.
field_list = [item.strip() for item in fields.split(",")] if fields is not None else None

with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
Expand All @@ -712,13 +796,28 @@ def find(
depth=depth,
page=page,
page_size=page_size,
meta=meta,
fields=field_list,
project=project,
project_id=project_id,
)
)
mode = _resolve_output_mode(json_output, plain)
if mode == "json":
_print_json(result)
elif "results" in result:
# --meta flips the payload to the search response shape: projected
# fields get the dedicated renderers, otherwise grep's search
# renderers apply.
if field_list:
if mode == "plain":
_plain_find_fields(result)
else:
_display_find_fields(result, path, meta or [], field_list)
elif mode == "plain":
_plain_search_results(result, query=" AND ".join(meta or []))
else:
_display_search_results(result, query=" AND ".join(meta or []))
elif mode == "plain":
_plain_find(result)
else:
Expand Down
3 changes: 2 additions & 1 deletion src/basic_memory/man/bm.1
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ Search note content (semantic by default; \-F for literal matching).
List one directory level of a project.
.TP
.B bm find
Recursively list files matching a name glob.
Recursively list files by name glob, or query notes by frontmatter
metadata (\-\-meta).
.TP
.B bm tree
Show a directory hierarchy.
Expand Down
Loading
Loading