Skip to content

Commit 602c55f

Browse files
committed
only allow edit_note, move_note using strict identifier match
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 91bfe2d commit 602c55f

6 files changed

Lines changed: 199 additions & 31 deletions

File tree

.claude/commands/test-live.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ Execute comprehensive real-world testing of Basic Memory using the installed ver
1212

1313
## Implementation
1414

15-
You are an expert QA engineer conducting live testing of Basic Memory. When the user runs `/project:test-live`, execute comprehensive testing following the TESTING.md methodology:
15+
You are an expert QA engineer conducting live testing of Basic Memory.
16+
When the user runs `/project:test-live`, execute comprehensive testing following the TESTING.md methodology:
1617

1718
### Pre-Test Setup
1819

@@ -22,12 +23,17 @@ You are an expert QA engineer conducting live testing of Basic Memory. When the
2223
- Test MCP connection and tool availability
2324

2425
2. **Test Project Creation**
26+
27+
Run the bash `date` command to get the current date/time.
28+
2529
```
2630
Create project: "basic-memory-testing-[timestamp]"
2731
Location: ~/basic-memory-testing-[timestamp]
2832
Purpose: Record all test observations and results
2933
```
3034

35+
Make sure to switch to the newly created project with the `switch_project()` tool.
36+
3137
3. **Baseline Documentation**
3238
Create initial test session note with:
3339
- Test environment details

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ def _format_error_response(
2424
if "Entity not found" in error_message or "entity not found" in error_message.lower():
2525
return f"""# Edit Failed - Note Not Found
2626
27-
The note with identifier '{identifier}' could not be found.
27+
The note with identifier '{identifier}' could not be found. Edit operations require an exact match (no fuzzy matching).
2828
2929
## Suggestions to try:
30-
1. **Search for the note first**: Use `search_notes("{identifier.split("/")[-1]}")` to find similar notes
31-
2. **Try different identifier formats**:
32-
- If you used a permalink like "folder/note-title", try just the title: "{identifier.split("/")[-1].replace("-", " ").title()}"
33-
- If you used a title, try the permalink format: "{identifier.lower().replace(" ", "-")}"
34-
- Use `read_note()` first to verify the note exists and get the correct identifiers
30+
1. **Search for the note first**: Use `search_notes("{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
31+
2. **Try different exact identifier formats**:
32+
- If you used a permalink like "folder/note-title", try the exact title: "{identifier.split("/")[-1].replace("-", " ").title()}"
33+
- If you used a title, try the exact permalink format: "{identifier.lower().replace(" ", "-")}"
34+
- Use `read_note()` first to verify the note exists and get the exact identifier
3535
3636
## Alternative approach:
3737
Use `write_note()` to create the note first, then edit it."""
@@ -142,7 +142,9 @@ async def edit_note(
142142
It supports various operations for different editing scenarios.
143143
144144
Args:
145-
identifier: The title, permalink, or memory:// URL of the note to edit
145+
identifier: The exact title, permalink, or memory:// URL of the note to edit.
146+
Must be an exact match - fuzzy matching is not supported for edit operations.
147+
Use search_notes() or read_note() first to find the correct identifier if uncertain.
146148
operation: The editing operation to perform:
147149
- "append": Add content to the end of the note
148150
- "prepend": Add content to the beginning of the note
@@ -179,10 +181,14 @@ async def edit_note(
179181
# Replace subsection with more specific header
180182
edit_note("docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
181183
182-
# Using different identifier formats
183-
edit_note("Meeting Notes", "append", "\\n- Follow up on action items") # title
184-
edit_note("docs/meeting-notes", "append", "\\n- Follow up tasks") # permalink
185-
edit_note("docs/Meeting Notes", "append", "\\n- Next steps") # folder/title
184+
# Using different identifier formats (must be exact matches)
185+
edit_note("Meeting Notes", "append", "\\n- Follow up on action items") # exact title
186+
edit_note("docs/meeting-notes", "append", "\\n- Follow up tasks") # exact permalink
187+
edit_note("docs/Meeting Notes", "append", "\\n- Next steps") # exact folder/title
188+
189+
# If uncertain about identifier, search first:
190+
# search_notes("meeting") # Find available notes
191+
# edit_note("docs/meeting-notes-2025", "append", "content") # Use exact result
186192
187193
# Add new section to document
188194
edit_note("project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")

src/basic_memory/mcp/tools/move_note.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ def _format_move_error_response(error_message: str, identifier: str, destination
2626
return dedent(f"""
2727
# Move Failed - Note Not Found
2828
29-
The note '{identifier}' could not be found for moving.
29+
The note '{identifier}' could not be found for moving. Move operations require an exact match (no fuzzy matching).
3030
3131
## Suggestions to try:
32-
1. **Search for the note first**: Use `search_notes("{search_term}")` to find it
33-
2. **Try different identifier formats**:
34-
- If you used a permalink like "folder/note-title", try just the title: "{title_format}"
35-
- If you used a title, try the permalink format: "{permalink_format}"
36-
- Use `read_note()` first to verify the note exists and get the correct identifier
32+
1. **Search for the note first**: Use `search_notes("{search_term}")` to find it with exact identifiers
33+
2. **Try different exact identifier formats**:
34+
- If you used a permalink like "folder/note-title", try the exact title: "{title_format}"
35+
- If you used a title, try the exact permalink format: "{permalink_format}"
36+
- Use `read_note()` first to verify the note exists and get the exact identifier
3737
3838
3. **Check current project**: Use `get_current_project()` to verify you're in the right project
3939
4. **List available notes**: Use `list_directory("/")` to see what notes exist
@@ -43,7 +43,7 @@ def _format_move_error_response(error_message: str, identifier: str, destination
4343
# First, verify the note exists:
4444
search_notes("{identifier}")
4545
46-
# Then use the correct identifier from search results:
46+
# Then use the exact identifier from search results:
4747
move_note("correct-identifier-here", "{destination_path}")
4848
```
4949
""").strip()
@@ -220,17 +220,28 @@ async def move_note(
220220
"""Move a note to a new file location within the same project.
221221
222222
Args:
223-
identifier: Entity identifier (title, permalink, or memory:// URL)
223+
identifier: Exact entity identifier (title, permalink, or memory:// URL).
224+
Must be an exact match - fuzzy matching is not supported for move operations.
225+
Use search_notes() or read_note() first to find the correct identifier if uncertain.
224226
destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
225227
project: Optional project name (defaults to current session project)
226228
227229
Returns:
228230
Success message with move details
229231
230232
Examples:
231-
- Move to new folder: move_note("My Note", "work/notes/my-note.md")
232-
- Move by permalink: move_note("my-note-permalink", "archive/old-notes/my-note.md")
233-
- Specify project: move_note("My Note", "archive/my-note.md", project="work-project")
233+
# Move to new folder (exact title match)
234+
move_note("My Note", "work/notes/my-note.md")
235+
236+
# Move by exact permalink
237+
move_note("my-note-permalink", "archive/old-notes/my-note.md")
238+
239+
# Specify project with exact identifier
240+
move_note("My Note", "archive/my-note.md", project="work-project")
241+
242+
# If uncertain about identifier, search first:
243+
# search_notes("my note") # Find available notes
244+
# move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result
234245
235246
Note: This operation moves notes within the specified project only. Moving notes
236247
between different projects is not currently supported.

src/basic_memory/services/entity_service.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -413,8 +413,8 @@ async def edit_entity(
413413
"""
414414
logger.debug(f"Editing entity: {identifier}, operation: {operation}")
415415

416-
# Find the entity using the link resolver
417-
entity = await self.link_resolver.resolve_link(identifier)
416+
# Find the entity using the link resolver with strict mode for destructive operations
417+
entity = await self.link_resolver.resolve_link(identifier, strict=True)
418418
if not entity:
419419
raise EntityNotFoundError(f"Entity not found: {identifier}")
420420

@@ -630,8 +630,8 @@ async def move_entity(
630630
"""
631631
logger.debug(f"Moving entity: {identifier} to {destination_path}")
632632

633-
# 1. Resolve identifier to entity
634-
entity = await self.link_resolver.resolve_link(identifier)
633+
# 1. Resolve identifier to entity with strict mode for destructive operations
634+
entity = await self.link_resolver.resolve_link(identifier, strict=True)
635635
if not entity:
636636
raise EntityNotFoundError(f"Entity not found: {identifier}")
637637

src/basic_memory/services/link_resolver.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,14 @@ def __init__(self, entity_repository: EntityRepository, search_service: SearchSe
2626
self.entity_repository = entity_repository
2727
self.search_service = search_service
2828

29-
async def resolve_link(self, link_text: str, use_search: bool = True) -> Optional[Entity]:
30-
"""Resolve a markdown link to a permalink."""
29+
async def resolve_link(self, link_text: str, use_search: bool = True, strict: bool = False) -> Optional[Entity]:
30+
"""Resolve a markdown link to a permalink.
31+
32+
Args:
33+
link_text: The link text to resolve
34+
use_search: Whether to use search-based fuzzy matching as fallback
35+
strict: If True, only exact matches are allowed (no fuzzy search fallback)
36+
"""
3137
logger.trace(f"Resolving link: {link_text}")
3238

3339
# Clean link text and extract any alias
@@ -60,9 +66,12 @@ async def resolve_link(self, link_text: str, use_search: bool = True) -> Optiona
6066
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
6167
return found_path_md
6268

63-
# search if indicated
69+
# In strict mode, don't try fuzzy search - return None if no exact match found
70+
if strict:
71+
return None
72+
73+
# 5. Fall back to search for fuzzy matching (only if not in strict mode)
6474
if use_search and "*" not in clean_text:
65-
# 5. Fall back to search for fuzzy matching on title (use text search for prefix matching)
6675
results = await self.search_service.search(
6776
query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
6877
)

tests/services/test_link_resolver.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,139 @@ async def test_folder_title_pattern_with_md_extension(link_resolver, test_entiti
220220
entity = await link_resolver.resolve_link("components/core-service")
221221
assert entity is not None
222222
assert entity.permalink == "components/core-service"
223+
224+
225+
# Tests for strict mode parameter combinations
226+
@pytest.mark.asyncio
227+
async def test_strict_mode_parameter_combinations(link_resolver, test_entities):
228+
"""Test all combinations of use_search and strict parameters."""
229+
230+
# Test queries
231+
exact_match = "Auth Service" # Should always work (unique title)
232+
fuzzy_match = "Auth Serv" # Should only work with fuzzy search enabled
233+
non_existent = "Does Not Exist" # Should never work
234+
235+
# Case 1: use_search=True, strict=False (default behavior - fuzzy matching allowed)
236+
result = await link_resolver.resolve_link(exact_match, use_search=True, strict=False)
237+
assert result is not None
238+
assert result.permalink == "components/auth-service"
239+
240+
result = await link_resolver.resolve_link(fuzzy_match, use_search=True, strict=False)
241+
assert result is not None # Should find "Auth Service" via fuzzy matching
242+
assert result.permalink == "components/auth-service"
243+
244+
result = await link_resolver.resolve_link(non_existent, use_search=True, strict=False)
245+
assert result is None
246+
247+
# Case 2: use_search=True, strict=True (exact matches only, even with search enabled)
248+
result = await link_resolver.resolve_link(exact_match, use_search=True, strict=True)
249+
assert result is not None
250+
assert result.permalink == "components/auth-service"
251+
252+
result = await link_resolver.resolve_link(fuzzy_match, use_search=True, strict=True)
253+
assert result is None # Should NOT find via fuzzy matching in strict mode
254+
255+
result = await link_resolver.resolve_link(non_existent, use_search=True, strict=True)
256+
assert result is None
257+
258+
# Case 3: use_search=False, strict=False (no search, exact repository matches only)
259+
result = await link_resolver.resolve_link(exact_match, use_search=False, strict=False)
260+
assert result is not None
261+
assert result.permalink == "components/auth-service"
262+
263+
result = await link_resolver.resolve_link(fuzzy_match, use_search=False, strict=False)
264+
assert result is None # No search means no fuzzy matching
265+
266+
result = await link_resolver.resolve_link(non_existent, use_search=False, strict=False)
267+
assert result is None
268+
269+
# Case 4: use_search=False, strict=True (redundant but should work same as case 3)
270+
result = await link_resolver.resolve_link(exact_match, use_search=False, strict=True)
271+
assert result is not None
272+
assert result.permalink == "components/auth-service"
273+
274+
result = await link_resolver.resolve_link(fuzzy_match, use_search=False, strict=True)
275+
assert result is None # No search means no fuzzy matching
276+
277+
result = await link_resolver.resolve_link(non_existent, use_search=False, strict=True)
278+
assert result is None
279+
280+
281+
@pytest.mark.asyncio
282+
async def test_exact_match_types_in_strict_mode(link_resolver, test_entities):
283+
"""Test that all types of exact matches work in strict mode."""
284+
285+
# 1. Exact permalink match
286+
result = await link_resolver.resolve_link("components/core-service", strict=True)
287+
assert result is not None
288+
assert result.permalink == "components/core-service"
289+
290+
# 2. Exact title match
291+
result = await link_resolver.resolve_link("Core Service", strict=True)
292+
assert result is not None
293+
assert result.permalink == "components/core-service"
294+
295+
# 3. Exact file path match
296+
result = await link_resolver.resolve_link("components/Core Service.md", strict=True)
297+
assert result is not None
298+
assert result.permalink == "components/core-service"
299+
300+
# 4. Folder/title pattern with .md extension added
301+
result = await link_resolver.resolve_link("components/Core Service", strict=True)
302+
assert result is not None
303+
assert result.permalink == "components/core-service"
304+
305+
# 5. Non-markdown file (Image.png)
306+
result = await link_resolver.resolve_link("Image.png", strict=True)
307+
assert result is not None
308+
assert result.title == "Image.png"
309+
310+
311+
@pytest.mark.asyncio
312+
async def test_fuzzy_matching_blocked_in_strict_mode(link_resolver, test_entities):
313+
"""Test that various fuzzy matching scenarios are blocked in strict mode."""
314+
315+
# Partial matches that would work in normal mode
316+
fuzzy_queries = [
317+
"Auth Serv", # Partial title
318+
"auth-service", # Lowercase permalink variation
319+
"Core", # Single word from title
320+
"Service", # Common word
321+
"Serv", # Partial word
322+
]
323+
324+
for query in fuzzy_queries:
325+
# Should NOT work in strict mode
326+
strict_result = await link_resolver.resolve_link(query, strict=True)
327+
assert strict_result is None, f"Query '{query}' should return None in strict mode"
328+
329+
330+
@pytest.mark.asyncio
331+
async def test_link_normalization_with_strict_mode(link_resolver, test_entities):
332+
"""Test that link normalization still works in strict mode."""
333+
334+
# Test bracket removal and alias handling in strict mode
335+
queries_and_expected = [
336+
("[[Core Service]]", "components/core-service"),
337+
("[[Core Service|Main]]", "components/core-service"), # Alias should be ignored
338+
(" [[ Core Service ]] ", "components/core-service"), # Extra whitespace
339+
]
340+
341+
for query, expected_permalink in queries_and_expected:
342+
result = await link_resolver.resolve_link(query, strict=True)
343+
assert result is not None, f"Query '{query}' should find entity in strict mode"
344+
assert result.permalink == expected_permalink
345+
346+
347+
@pytest.mark.asyncio
348+
async def test_duplicate_title_handling_in_strict_mode(link_resolver, test_entities):
349+
"""Test how duplicate titles are handled in strict mode."""
350+
351+
# "Core Service" appears twice in test data (components/core-service and components2/core-service)
352+
# In strict mode, if there are multiple exact title matches, it should still return the first one
353+
# (same behavior as normal mode for exact matches)
354+
355+
result = await link_resolver.resolve_link("Core Service", strict=True)
356+
assert result is not None
357+
# Should return the first match (components/core-service based on test fixture order)
358+
assert result.permalink == "components/core-service"

0 commit comments

Comments
 (0)