Skip to content

Commit c4dcf8e

Browse files
phernandezclaude
andcommitted
feat: add metadata filter support to bm_search tool and register new skills
Extend bm_search with optional metadata_filters, tags, and status parameters so the agent has one unified search tool for both text and structured queries. Register memory-notes and memory-metadata-search as slash command skills. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e29d611 commit c4dcf8e

File tree

8 files changed

+658
-6
lines changed

8 files changed

+658
-6
lines changed

bm-client.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,64 @@ describe("BmClient MCP behavior", () => {
172172
expect(results[0].title).toBe("x")
173173
})
174174

175+
it("search passes metadata_filters, tags, and status to search_notes", async () => {
176+
const callTool = jest.fn().mockResolvedValue(
177+
mcpResult({
178+
results: [
179+
{
180+
title: "Auth Design",
181+
permalink: "auth-design",
182+
content: "OAuth spec",
183+
file_path: "specs/auth-design.md",
184+
score: 0.85,
185+
},
186+
],
187+
}),
188+
)
189+
setConnected(client, callTool)
190+
191+
const results = await client.search("oauth", 5, "research", {
192+
filters: { type: "spec", confidence: { $gt: 0.7 } },
193+
tags: ["security"],
194+
status: "in-progress",
195+
})
196+
197+
expect(callTool).toHaveBeenCalledWith({
198+
name: "search_notes",
199+
arguments: {
200+
query: "oauth",
201+
page: 1,
202+
page_size: 5,
203+
output_format: "json",
204+
project: "research",
205+
metadata_filters: { type: "spec", confidence: { $gt: 0.7 } },
206+
tags: ["security"],
207+
status: "in-progress",
208+
},
209+
})
210+
expect(results).toHaveLength(1)
211+
expect(results[0].title).toBe("Auth Design")
212+
})
213+
214+
it("search omits metadata args when not provided", async () => {
215+
const callTool = jest.fn().mockResolvedValue(
216+
mcpResult({ results: [] }),
217+
)
218+
setConnected(client, callTool)
219+
220+
await client.search("test", 10)
221+
222+
expect(callTool).toHaveBeenCalledWith({
223+
name: "search_notes",
224+
arguments: {
225+
query: "test",
226+
page: 1,
227+
page_size: 10,
228+
output_format: "json",
229+
},
230+
})
231+
})
232+
175233
it("buildContext calls build_context using output_format=json", async () => {
176234
const callTool = jest.fn().mockResolvedValue(
177235
mcpResult({

bm-client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,11 @@ export class BmClient {
499499
query: string,
500500
limit = 10,
501501
project?: string,
502+
metadata?: {
503+
filters?: Record<string, unknown>
504+
tags?: string[]
505+
status?: string
506+
},
502507
): Promise<SearchResult[]> {
503508
const args: Record<string, unknown> = {
504509
query,
@@ -507,6 +512,9 @@ export class BmClient {
507512
output_format: "json",
508513
}
509514
if (project) args.project = project
515+
if (metadata?.filters) args.metadata_filters = metadata.filters
516+
if (metadata?.tags) args.tags = metadata.tags
517+
if (metadata?.status) args.status = metadata.status
510518

511519
const payload = await this.callTool("search_notes", args)
512520

commands/skills.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,19 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
33
import { registerSkillCommands } from "./skills.ts"
44

55
describe("skill slash commands", () => {
6-
it("should register all four skill commands", () => {
6+
it("should register all skill commands", () => {
77
const mockApi = {
88
registerCommand: jest.fn(),
99
} as unknown as OpenClawPluginApi
1010

1111
registerSkillCommands(mockApi)
1212

13-
expect(mockApi.registerCommand).toHaveBeenCalledTimes(4)
13+
expect(mockApi.registerCommand).toHaveBeenCalledTimes(6)
1414

1515
const names = (
1616
mockApi.registerCommand as jest.MockedFunction<any>
1717
).mock.calls.map((call: any[]) => call[0].name)
18-
expect(names).toEqual(["tasks", "reflect", "defrag", "schema"])
18+
expect(names).toEqual(["tasks", "reflect", "defrag", "schema", "notes", "metadata-search"])
1919
})
2020

2121
it("should set correct metadata on each command", () => {
@@ -91,6 +91,12 @@ describe("skill slash commands", () => {
9191

9292
const schemaResult = await commands.schema.handler({})
9393
expect(schemaResult.text).toContain("## Picoschema Syntax Reference")
94+
95+
const notesResult = await commands.notes.handler({})
96+
expect(notesResult.text).toContain("## Note Anatomy")
97+
98+
const metadataResult = await commands["metadata-search"].handler({})
99+
expect(metadataResult.text).toContain("## Filter Syntax")
94100
})
95101
})
96102
})

commands/skills.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const SKILLS = [
1919
},
2020
{ name: "defrag", dir: "memory-defrag", desc: "Memory defrag workflow" },
2121
{ name: "schema", dir: "memory-schema", desc: "Schema management workflow" },
22+
{ name: "notes", dir: "memory-notes", desc: "How to write well-structured notes" },
23+
{ name: "metadata-search", dir: "memory-metadata-search", desc: "Structured metadata search workflow" },
2224
] as const
2325

2426
export function registerSkillCommands(api: OpenClawPluginApi): void {
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
---
2+
name: memory-metadata-search
3+
description: "Structured metadata search for Basic Memory: query notes by custom frontmatter fields using equality, range, array, and nested filters. Use when finding notes by status, priority, confidence, or any custom YAML field rather than free-text content."
4+
---
5+
6+
# Memory Metadata Search
7+
8+
Find notes by their structured frontmatter fields instead of (or in addition to) free-text content. Any custom YAML key in a note's frontmatter beyond the standard set (`title`, `type`, `tags`, `permalink`, `schema`) is automatically indexed as `entity_metadata` and becomes queryable.
9+
10+
## When to Use
11+
12+
- **Filtering by status or priority** — find all notes with `status: draft` or `priority: high`
13+
- **Querying custom fields** — any frontmatter key you invent is searchable
14+
- **Range queries** — find notes with `confidence > 0.7` or `score between 0.3 and 0.8`
15+
- **Combining text + metadata** — narrow a text search with structured constraints
16+
- **Tag-based filtering** — find notes tagged with specific frontmatter tags
17+
- **Schema-aware queries** — filter by nested schema fields using dot notation
18+
19+
## Tool
20+
21+
Use the `bm_search` tool with the optional `metadata_filters`, `tags`, and `status` parameters.
22+
23+
| Parameter | Type | Description |
24+
|-----------|------|-------------|
25+
| `query` | string | Text search query (can be empty for filter-only searches) |
26+
| `metadata_filters` | object | Filter by frontmatter fields (see filter syntax below) |
27+
| `tags` | string[] | Filter by frontmatter tags (all must match) |
28+
| `status` | string | Filter by frontmatter status field |
29+
30+
## Filter Syntax
31+
32+
Filters are a JSON dictionary. Each key targets a frontmatter field; the value specifies the match condition. Multiple keys combine with **AND** logic.
33+
34+
### Equality
35+
36+
```json
37+
{"status": "active"}
38+
```
39+
40+
### Array Contains (all listed values must be present)
41+
42+
```json
43+
{"tags": ["security", "oauth"]}
44+
```
45+
46+
### `$in` (match any value in list)
47+
48+
```json
49+
{"priority": {"$in": ["high", "critical"]}}
50+
```
51+
52+
### Comparisons (`$gt`, `$gte`, `$lt`, `$lte`)
53+
54+
```json
55+
{"confidence": {"$gt": 0.7}}
56+
```
57+
58+
Numeric values use numeric comparison; strings use lexicographic comparison.
59+
60+
### `$between` (inclusive range)
61+
62+
```json
63+
{"score": {"$between": [0.3, 0.8]}}
64+
```
65+
66+
### Nested Access (dot notation)
67+
68+
```json
69+
{"schema.version": "2"}
70+
```
71+
72+
### Quick Reference
73+
74+
| Operator | Syntax | Example |
75+
|----------|--------|---------|
76+
| Equality | `{"field": "value"}` | `{"status": "active"}` |
77+
| Array contains | `{"field": ["a", "b"]}` | `{"tags": ["security", "oauth"]}` |
78+
| `$in` | `{"field": {"$in": [...]}}` | `{"priority": {"$in": ["high", "critical"]}}` |
79+
| `$gt` / `$gte` | `{"field": {"$gt": N}}` | `{"confidence": {"$gt": 0.7}}` |
80+
| `$lt` / `$lte` | `{"field": {"$lt": N}}` | `{"score": {"$lt": 0.5}}` |
81+
| `$between` | `{"field": {"$between": [lo, hi]}}` | `{"score": {"$between": [0.3, 0.8]}}` |
82+
| Nested | `{"a.b": "value"}` | `{"schema.version": "2"}` |
83+
84+
**Rules:**
85+
- Keys must match `[A-Za-z0-9_-]+` (dots separate nesting levels)
86+
- Operator dicts must contain exactly one operator
87+
- `$in` and array-contains require non-empty lists
88+
- `$between` requires exactly `[min, max]`
89+
90+
## Examples
91+
92+
### Metadata-only search (empty query)
93+
94+
```
95+
bm_search(query="", metadata_filters={"status": "in-progress", "type": "spec"})
96+
```
97+
98+
### Text search narrowed by metadata
99+
100+
```
101+
bm_search(query="authentication", metadata_filters={"status": "draft"})
102+
```
103+
104+
### Filter by tags
105+
106+
```
107+
bm_search(query="", tags=["security", "oauth"])
108+
```
109+
110+
### Filter by status shortcut
111+
112+
```
113+
bm_search(query="planning", status="active")
114+
```
115+
116+
### Combined text + metadata + tags
117+
118+
```
119+
bm_search(
120+
query="oauth flow",
121+
metadata_filters={"confidence": {"$gt": 0.7}},
122+
tags=["security"],
123+
status="in-progress",
124+
)
125+
```
126+
127+
### High-priority notes in a specific project
128+
129+
```
130+
bm_search(
131+
query="",
132+
metadata_filters={"priority": {"$in": ["high", "critical"]}},
133+
project="research",
134+
limit=10,
135+
)
136+
```
137+
138+
### Numeric range query
139+
140+
```
141+
bm_search(query="", metadata_filters={"score": {"$between": [0.3, 0.8]}})
142+
```
143+
144+
## Tag Search Shorthand
145+
146+
The `tag:` prefix in a query converts to a tag filter automatically:
147+
148+
```
149+
# These are equivalent:
150+
bm_search(query="tag:tier1")
151+
bm_search(query="", tags=["tier1"])
152+
153+
# Multiple tags (comma or space separated) — all must match:
154+
bm_search(query="tag:tier1,alpha")
155+
```
156+
157+
## Example: Custom Frontmatter in Practice
158+
159+
A note with custom fields:
160+
161+
```markdown
162+
---
163+
title: Auth Design
164+
type: spec
165+
tags: [security, oauth]
166+
status: in-progress
167+
priority: high
168+
confidence: 0.85
169+
---
170+
171+
# Auth Design
172+
173+
## Observations
174+
- [decision] Use OAuth 2.1 with PKCE for all client types #security
175+
- [requirement] Token refresh must be transparent to the user
176+
177+
## Relations
178+
- implements [[Security Requirements]]
179+
```
180+
181+
Queries that find it:
182+
183+
```
184+
# By status and type
185+
bm_search(query="", metadata_filters={"status": "in-progress", "type": "spec"})
186+
187+
# By numeric threshold
188+
bm_search(query="", metadata_filters={"confidence": {"$gt": 0.7}})
189+
190+
# By priority set
191+
bm_search(query="", metadata_filters={"priority": {"$in": ["high", "critical"]}})
192+
193+
# By tag shorthand
194+
bm_search(query="tag:security")
195+
196+
# Combined text + metadata
197+
bm_search(query="OAuth", metadata_filters={"status": "in-progress"})
198+
```
199+
200+
## Guidelines
201+
202+
- **Use metadata filters for structured queries.** If you're looking for notes by a known field value (status, priority, type), metadata filters are more precise than text search.
203+
- **Use text search for content queries.** If you're looking for notes *about* something, text search is better. Combine both when you need precision.
204+
- **Custom fields are free.** Any YAML key you put in frontmatter becomes queryable — no schema or configuration required.
205+
- **Multiple filters are AND.** `{"status": "active", "priority": "high"}` requires both conditions.
206+
- **Use empty query for filter-only searches.** Pass `query=""` with `metadata_filters` when you only need structured filtering.
207+
- **Dot notation for nesting.** Access nested YAML structures with dots: `{"schema.version": "2"}` queries the `version` key inside a `schema` object.
208+
- **Tags and status are convenient shortcuts.** Use the dedicated `tags` and `status` parameters for common fields, or `metadata_filters` for anything else.

0 commit comments

Comments
 (0)