Skip to content

Commit 8b7f39e

Browse files
phernandezclaude
andcommitted
docs: update AI assistant guides for v0.19.0, fix multi-tag search parsing
Update both the compact and extended AI assistant guides with v0.19.0 changes: - 📝 write_note overwrite guard: callout, edit_note examples, overwrite=True - 🔍 Expanded search section: all search types, tag: shorthand, filter-only searches, metadata_filters operators, min_similarity - ⚠️ "Note already exists" error handling pattern - 📋 Tool quick reference: updated params, added list_workspaces - 🔗 memory:// URL: added cross-project format - ✏️ Best practice: prefer edit_note for updates Fix tag: shorthand parsing to handle multiple tags anywhere in the query. Old parser only handled queries starting with "tag:" and broke on "tag:coffee AND tag:brewing". New parser uses re.findall to extract all tag:value tokens, strips boolean connectors, and preserves remaining text. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent a368d06 commit 8b7f39e

4 files changed

Lines changed: 227 additions & 19 deletions

File tree

docs/ai-assistant-guide-extended.md

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,8 @@ await write_note(
427427
)
428428
```
429429

430+
> **Important**: `write_note` errors if the note already exists. Use `edit_note` for incremental changes, or pass `overwrite=True` to replace.
431+
430432
**Well-structured note**:
431433

432434
```python
@@ -760,6 +762,9 @@ notes = await read_note(
760762
identifier="memory://specs/*",
761763
project="main"
762764
)
765+
766+
# Cross-project URL (auto-routes to the correct project)
767+
note = await read_note(identifier="memory://research/specs/api-design")
763768
```
764769

765770
```python
@@ -1069,7 +1074,10 @@ results = await search_notes(
10691074

10701075
### Search Types
10711076

1072-
**Text search (default)**:
1077+
Available types: `"text"`, `"title"`, `"permalink"`, `"vector"`/`"semantic"`, `"hybrid"`.
1078+
Default is `"hybrid"` when semantic search is enabled, `"text"` otherwise.
1079+
1080+
**Text search**:
10731081

10741082
```python
10751083
# Full-text search across all content
@@ -1080,17 +1088,52 @@ results = await search_notes(
10801088
)
10811089
```
10821090

1083-
**Semantic search**:
1091+
**Title and permalink search**:
1092+
1093+
```python
1094+
# Search by title only
1095+
results = await search_notes(query="API Design", search_type="title", project="main")
1096+
1097+
# Search by permalink
1098+
results = await search_notes(query="specs/api-design", search_type="permalink", project="main")
1099+
```
1100+
1101+
**Semantic/vector search**:
10841102

10851103
```python
10861104
# Semantic/vector search (if enabled)
1105+
results = await search_notes(
1106+
query="user login security",
1107+
search_type="semantic", # or "vector"
1108+
project="main"
1109+
)
1110+
1111+
# Override similarity threshold
10871112
results = await search_notes(
10881113
query="user login security",
10891114
search_type="semantic",
1115+
min_similarity=0.5,
10901116
project="main"
10911117
)
10921118
```
10931119

1120+
**Hybrid search** (combines text + semantic):
1121+
1122+
```python
1123+
results = await search_notes(
1124+
query="authentication best practices",
1125+
search_type="hybrid",
1126+
project="main"
1127+
)
1128+
```
1129+
1130+
**Tag shorthand in query**:
1131+
1132+
```python
1133+
# Use tag: prefix as shorthand
1134+
results = await search_notes(query="tag:security", project="main")
1135+
```
1136+
10941137
### Search Response
10951138

10961139
**Result structure**:
@@ -2161,6 +2204,31 @@ active_project = projects[0]["name"]
21612204
results = await search_notes(query="test", project=active_project)
21622205
```
21632206

2207+
### Note Already Exists
2208+
2209+
**Error**: `write_note` called for a note that already exists
2210+
2211+
**Solution**:
2212+
2213+
```python
2214+
# Preferred: use edit_note for incremental updates
2215+
await edit_note(
2216+
identifier="Existing Topic",
2217+
operation="append",
2218+
content="\n- [update] new information",
2219+
project="main"
2220+
)
2221+
2222+
# Alternative: replace the entire note
2223+
await write_note(
2224+
title="Existing Topic",
2225+
content="# Existing Topic\n...",
2226+
folder="notes",
2227+
overwrite=True,
2228+
project="main"
2229+
)
2230+
```
2231+
21642232
### Entity Not Found
21652233

21662234
**Error**: Note doesn't exist
@@ -2716,14 +2784,15 @@ await write_note(
27162784

27172785
### Content Management
27182786

2719-
**write_note(title, content, folder, tags, note_type, project)**
2720-
- Create or update markdown notes
2787+
**write_note(title, content, folder, tags, note_type, overwrite, project)**
2788+
- Create new markdown notes (errors if note already exists unless overwrite=True)
27212789
- Parameters:
27222790
- `title` (required): Note title
27232791
- `content` (required): Markdown content
27242792
- `folder` (required): Destination folder
27252793
- `tags` (optional): List of tags
27262794
- `note_type` (optional): Type of note (stored in frontmatter). Can be "note", "person", "meeting", "guide", etc.
2795+
- `overwrite` (optional): Set to True to replace an existing note (default: error if exists)
27272796
- `project` (required unless default_project_mode): Target project
27282797
- Returns: Created/updated entity with permalink
27292798
- Example:
@@ -2890,19 +2959,20 @@ contents = await list_directory(
28902959

28912960
### Search & Discovery
28922961

2893-
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project)**
2962+
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, min_similarity, project)**
28942963
- Search across knowledge base
28952964
- Parameters:
2896-
- `query` (required): Search query
2965+
- `query` (optional): Search query (not required for filter-only searches)
28972966
- `page` (optional): Page number (default: 1)
28982967
- `page_size` (optional): Results per page (default: 10)
2899-
- `search_type` (optional): "text" or "semantic"
2968+
- `search_type` (optional): "text", "title", "permalink", "vector"/"semantic", "hybrid" (default: "hybrid" when semantic enabled, "text" otherwise)
29002969
- `types` (optional): Entity type filter
29012970
- `entity_types` (optional): Observation category filter
29022971
- `after_date` (optional): Date filter (ISO format)
2903-
- `metadata_filters` (optional): Structured frontmatter filters (dict)
2904-
- `tags` (optional): Frontmatter tags filter (list)
2972+
- `metadata_filters` (optional): Structured frontmatter filters (dict, supports `$in`, `$gt`, `$gte`, `$lt`, `$lte`, `$between` operators)
2973+
- `tags` (optional): Frontmatter tags filter (list); also available via `tag:` query shorthand
29052974
- `status` (optional): Frontmatter status filter (string)
2975+
- `min_similarity` (optional): Override similarity threshold for vector/hybrid search
29062976
- `project` (required unless default_project_mode): Target project
29072977
- Returns: Matching entities with scores
29082978
- Example:
@@ -2971,6 +3041,15 @@ await delete_project(project_name="old-project")
29713041
status = await sync_status(project="main")
29723042
```
29733043

3044+
**list_workspaces()**
3045+
- List available workspaces (cloud)
3046+
- Parameters: None
3047+
- Returns: List of workspaces with metadata
3048+
- Example:
3049+
```python
3050+
workspaces = await list_workspaces()
3051+
```
3052+
29743053
### Visualization
29753054

29763055
**canvas(nodes, edges, title, folder, project)**
@@ -3239,8 +3318,8 @@ await edit_note(
32393318
project="main"
32403319
)
32413320

3242-
# Avoid: Complete rewrite
3243-
# (unless necessary for major restructuring)
3321+
# When full rewrite is needed, use overwrite=True
3322+
await write_note(title="Note", content="...", folder="notes", overwrite=True)
32443323
```
32453324

32463325
### 14. Tagging Strategy

src/basic_memory/mcp/resources/ai_assistant_guide.md

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ await write_note(
5757
)
5858
```
5959

60+
> **Important**: `write_note` errors if the note already exists. Use `edit_note` for incremental changes, or pass `overwrite=True` to replace.
61+
62+
```python
63+
# Preferred: update an existing note incrementally
64+
await edit_note(identifier="Topic", operation="append", content="\n- [category] new fact")
65+
66+
# Alternative: replace the entire note
67+
await write_note(title="Topic", content="...", folder="notes", overwrite=True)
68+
```
69+
6070
### Reading Knowledge
6171

6272
```python
@@ -70,11 +80,27 @@ content = await read_note("memory://folder/topic", project="main")
7080
### Searching
7181

7282
```python
83+
# Basic text search
84+
results = await search_notes(query="authentication", project="main")
85+
86+
# Search types: "text" (default), "title", "permalink", "vector"/"semantic", "hybrid"
87+
# Default is "hybrid" when semantic search is enabled, "text" otherwise
88+
results = await search_notes(query="auth flow", search_type="hybrid")
89+
90+
# Tag shorthand in query (multiple tags: "tag:x AND tag:y" or "tag:x tag:y")
91+
results = await search_notes(query="tag:security")
92+
results = await search_notes(query="tag:coffee AND tag:brewing")
93+
94+
# Filter-only search (no query needed)
95+
results = await search_notes(tags=["security", "auth"], status="active")
96+
97+
# Metadata filters with operators: $in, $gt, $gte, $lt, $lte, $between
7398
results = await search_notes(
74-
query="authentication",
75-
project="main",
76-
page_size=10
99+
metadata_filters={"priority": {"$in": ["high", "critical"]}}
77100
)
101+
102+
# Override similarity threshold for vector/hybrid search
103+
results = await search_notes(query="auth", search_type="hybrid", min_similarity=0.5)
78104
```
79105

80106
### Building Context
@@ -162,6 +188,8 @@ activity = await recent_activity(project="main")
162188
- 2-3 relations per note
163189
- Meaningful categories and relation types
164190

191+
**Prefer `edit_note` for updates** — use `write_note` only for new notes.
192+
165193
**Search before creating:**
166194
```python
167195
# Find existing entities to reference
@@ -201,6 +229,14 @@ except:
201229
results = await search_notes(query="test", project=projects[0].name)
202230
```
203231

232+
**Note already exists:**
233+
```python
234+
# write_note returns an error if the note exists — use edit_note or overwrite
235+
await edit_note(identifier="Existing Topic", operation="append", content="\n- [update] new info")
236+
# Or replace entirely:
237+
await write_note(title="Existing Topic", content="...", folder="notes", overwrite=True)
238+
```
239+
204240
**Forward references:**
205241
```python
206242
# Check response for unresolved relations
@@ -256,20 +292,22 @@ context = await build_context(url=f"memory://{results[0].permalink}", project="m
256292

257293
| Tool | Purpose | Key Params |
258294
|------|---------|------------|
259-
| `write_note` | Create/update | title, content, folder, project |
295+
| `write_note` | Create new | title, content, folder, project, overwrite |
260296
| `read_note` | Read content | identifier, project |
261297
| `edit_note` | Modify existing | identifier, operation, content, project |
262-
| `search_notes` | Find notes | query, project |
298+
| `search_notes` | Find notes | query, search_type, tags, metadata_filters, project |
263299
| `build_context` | Graph traversal | url, depth, project |
264300
| `recent_activity` | Recent changes | timeframe, project |
265301
| `list_memory_projects` | Show projects | (none) |
302+
| `list_workspaces` | Show workspaces | (none) |
266303

267304
## memory:// URL Format
268305

269306
- `memory://title` - By title
270307
- `memory://folder/title` - By folder + title
271308
- `memory://permalink` - By permalink
272309
- `memory://folder/*` - All in folder
310+
- `memory://project-name/folder/title` - Cross-project (auto-routes to the correct project)
273311

274312
For full documentation: https://docs.basicmemory.com
275313

src/basic_memory/mcp/tools/search.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -440,14 +440,20 @@ async def search_notes(
440440
entity_types = entity_types or []
441441

442442
# Parse tag:<value> shorthand at tool level so it works with all search modes.
443+
# Handles "tag:security", "tag:coffee tag:brewing", "tag:coffee AND tag:brewing".
443444
# Without this, hybrid/vector modes fail because they require non-empty text,
444445
# but the service-layer tag: parser clears the text after the mode is set.
445-
if query and query.strip().lower().startswith("tag:"):
446-
tag_values = [t for t in re.split(r"[,\s]+", query.strip()[4:].strip()) if t]
446+
if query and "tag:" in query.lower():
447+
# Extract tag values, splitting comma-separated lists (e.g. "tag:coffee,brewing")
448+
raw_values = re.findall(r"tag:(\S+)", query, flags=re.IGNORECASE)
449+
tag_values = [v for raw in raw_values for v in raw.split(",") if v]
447450
if tag_values:
448451
# Merge with any explicitly provided tags
449452
tags = list(set((tags or []) + tag_values))
450-
query = None
453+
# Remove tag: tokens and boolean connectors, keep remaining text as query
454+
remainder = re.sub(r"tag:\S+", "", query, flags=re.IGNORECASE)
455+
remainder = re.sub(r"\b(AND|OR|NOT)\b", "", remainder).strip()
456+
query = remainder or None
451457

452458
# Detect project from memory URL prefix before routing
453459
if project is None and query is not None:

tests/mcp/test_tool_search.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,3 +1202,88 @@ async def search(self, payload, page, page_size):
12021202
assert isinstance(result, SearchResponse)
12031203
assert set(captured_payload["tags"]) == {"security", "oauth"}
12041204
assert captured_payload.get("text") is None
1205+
1206+
1207+
@pytest.mark.asyncio
1208+
async def test_search_notes_multiple_tag_prefixes(monkeypatch):
1209+
"""query='tag:coffee AND tag:brewing' should extract both tags."""
1210+
import importlib
1211+
1212+
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
1213+
clients_mod = importlib.import_module("basic_memory.mcp.clients")
1214+
1215+
class StubProject:
1216+
name = "test-project"
1217+
external_id = "test-external-id"
1218+
1219+
@asynccontextmanager
1220+
async def fake_get_project_client(*args, **kwargs):
1221+
yield (object(), StubProject())
1222+
1223+
captured_payload: dict = {}
1224+
1225+
class MockSearchClient:
1226+
def __init__(self, *args, **kwargs):
1227+
pass
1228+
1229+
async def search(self, payload, page, page_size):
1230+
captured_payload.update(payload)
1231+
return SearchResponse(results=[], current_page=page, page_size=page_size)
1232+
1233+
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
1234+
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
1235+
1236+
result = await search_mod.search_notes(
1237+
project="test-project",
1238+
query="tag:coffee AND tag:brewing",
1239+
)
1240+
1241+
assert isinstance(result, SearchResponse)
1242+
assert set(captured_payload["tags"]) == {"coffee", "brewing"}
1243+
# Boolean connector AND should be stripped, leaving no text query
1244+
assert captured_payload.get("text") is None
1245+
1246+
1247+
@pytest.mark.asyncio
1248+
async def test_search_notes_tag_prefix_with_remaining_text(monkeypatch):
1249+
"""query='authentication tag:security' should keep text and extract tag."""
1250+
import importlib
1251+
1252+
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
1253+
clients_mod = importlib.import_module("basic_memory.mcp.clients")
1254+
1255+
class StubProject:
1256+
name = "test-project"
1257+
external_id = "test-external-id"
1258+
1259+
@asynccontextmanager
1260+
async def fake_get_project_client(*args, **kwargs):
1261+
yield (object(), StubProject())
1262+
1263+
captured_payload: dict = {}
1264+
1265+
class MockSearchClient:
1266+
def __init__(self, *args, **kwargs):
1267+
pass
1268+
1269+
async def search(self, payload, page, page_size):
1270+
captured_payload.update(payload)
1271+
return SearchResponse(results=[], current_page=page, page_size=page_size)
1272+
1273+
# Remaining text query triggers resolve_project_and_path, so stub it too
1274+
async def fake_resolve(client, query, project, context):
1275+
return project, query, False
1276+
1277+
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
1278+
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve)
1279+
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
1280+
1281+
result = await search_mod.search_notes(
1282+
project="test-project",
1283+
query="authentication tag:security",
1284+
)
1285+
1286+
assert isinstance(result, SearchResponse)
1287+
assert captured_payload["tags"] == ["security"]
1288+
# Remaining text should be preserved as the query
1289+
assert captured_payload["text"] == "authentication"

0 commit comments

Comments
 (0)