Skip to content

Commit 3e168b9

Browse files
groksrcclaude[bot]github-actions[bot]phernandez
authored
fix: move_note without file extension (#281)
Signed-off-by: Drew Cain <[email protected]> Signed-off-by: phernandez <[email protected]> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <[email protected]> Co-authored-by: phernandez <[email protected]>
1 parent 1091e11 commit 3e168b9

File tree

4 files changed

+190
-9
lines changed

4 files changed

+190
-9
lines changed

src/basic_memory/cli/commands/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,4 @@
1414
"import_chatgpt",
1515
"tool",
1616
"project",
17-
"cloud",
1817
]

src/basic_memory/cli/commands/cloud/core_commands.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,4 +395,4 @@ def unmount() -> None:
395395
@cloud_app.command("mount-status")
396396
def mount_status() -> None:
397397
"""Show current mount status."""
398-
show_mount_status()
398+
show_mount_status()

src/basic_memory/mcp/tools/move_note.py

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,16 @@ def _format_cross_project_error_response(
6565
"""Format error response for detected cross-project move attempts."""
6666
return dedent(f"""
6767
# Move Failed - Cross-Project Move Not Supported
68-
68+
6969
Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}').
70-
70+
7171
**Current project:** {current_project}
7272
**Target project:** {target_project}
73-
73+
7474
## Cross-project moves are not supported directly
75-
75+
7676
Notes can only be moved within the same project. To move content between projects, use this workflow:
77-
77+
7878
### Recommended approach:
7979
```
8080
# 1. Read the note content from current project
@@ -87,13 +87,13 @@ def _format_cross_project_error_response(
8787
delete_note("{identifier}", project="{current_project}")
8888
8989
```
90-
90+
9191
### Alternative: Stay in current project
9292
If you want to move the note within the **{current_project}** project only:
9393
```
9494
move_note("{identifier}", "new-folder/new-name.md")
9595
```
96-
96+
9797
## Available projects:
9898
Use `list_memory_projects()` to see all available projects.
9999
""").strip()
@@ -429,6 +429,79 @@ async def move_note(
429429
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
430430
return cross_project_error
431431

432+
# Get the source entity information for extension validation
433+
source_ext = "md" # Default to .md if we can't determine source extension
434+
try:
435+
# Fetch source entity information to get the current file extension
436+
url = f"{project_url}/knowledge/entities/{identifier}"
437+
response = await call_get(client, url)
438+
source_entity = EntityResponse.model_validate(response.json())
439+
if "." in source_entity.file_path:
440+
source_ext = source_entity.file_path.split(".")[-1]
441+
except Exception as e:
442+
# If we can't fetch the source entity, default to .md extension
443+
logger.debug(f"Could not fetch source entity for extension check: {e}")
444+
445+
# Validate that destination path includes a file extension
446+
if "." not in destination_path or not destination_path.split(".")[-1]:
447+
logger.warning(f"Move failed - no file extension provided: {destination_path}")
448+
return dedent(f"""
449+
# Move Failed - File Extension Required
450+
451+
The destination path '{destination_path}' must include a file extension (e.g., '.md').
452+
453+
## Valid examples:
454+
- `notes/my-note.md`
455+
- `projects/meeting-2025.txt`
456+
- `archive/old-program.sh`
457+
458+
## Try again with extension:
459+
```
460+
move_note("{identifier}", "{destination_path}.{source_ext}")
461+
```
462+
463+
All examples in Basic Memory expect file extensions to be explicitly provided.
464+
""").strip()
465+
466+
# Get the source entity to check its file extension
467+
try:
468+
# Fetch source entity information
469+
url = f"{project_url}/knowledge/entities/{identifier}"
470+
response = await call_get(client, url)
471+
source_entity = EntityResponse.model_validate(response.json())
472+
473+
# Extract file extensions
474+
source_ext = (
475+
source_entity.file_path.split(".")[-1] if "." in source_entity.file_path else ""
476+
)
477+
dest_ext = destination_path.split(".")[-1] if "." in destination_path else ""
478+
479+
# Check if extensions match
480+
if source_ext and dest_ext and source_ext.lower() != dest_ext.lower():
481+
logger.warning(
482+
f"Move failed - file extension mismatch: source={source_ext}, dest={dest_ext}"
483+
)
484+
return dedent(f"""
485+
# Move Failed - File Extension Mismatch
486+
487+
The destination file extension '.{dest_ext}' does not match the source file extension '.{source_ext}'.
488+
489+
To preserve file type consistency, the destination must have the same extension as the source.
490+
491+
## Source file:
492+
- Path: `{source_entity.file_path}`
493+
- Extension: `.{source_ext}`
494+
495+
## Try again with matching extension:
496+
```
497+
move_note("{identifier}", "{destination_path.rsplit(".", 1)[0]}.{source_ext}")
498+
```
499+
""").strip()
500+
except Exception as e:
501+
# If we can't fetch the source entity, log it but continue
502+
# This might happen if the identifier is not yet resolved
503+
logger.debug(f"Could not fetch source entity for extension check: {e}")
504+
432505
try:
433506
# Prepare move request
434507
move_data = {

tests/mcp/test_tool_move_note.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,115 @@ async def test_move_note_invalid_destination_path(client, test_project):
205205
assert "/absolute/path.md" in result or "Invalid" in result or "path" in result
206206

207207

208+
@pytest.mark.asyncio
209+
async def test_move_note_missing_file_extension(client, test_project):
210+
"""Test moving note without file extension in destination path."""
211+
# Create initial note
212+
await write_note.fn(
213+
project=test_project.name,
214+
title="ExtensionTest",
215+
folder="source",
216+
content="# Extension Test\nTesting extension validation.",
217+
)
218+
219+
# Test path without extension
220+
result = await move_note.fn(
221+
project=test_project.name,
222+
identifier="source/extension-test",
223+
destination_path="target/renamed-note",
224+
)
225+
226+
# Should return error about missing extension
227+
assert isinstance(result, str)
228+
assert "# Move Failed - File Extension Required" in result
229+
assert "must include a file extension" in result
230+
assert ".md" in result
231+
assert "renamed-note.md" in result # Should suggest adding .md
232+
233+
# Test path with empty extension (edge case)
234+
result = await move_note.fn(
235+
project=test_project.name,
236+
identifier="source/extension-test",
237+
destination_path="target/renamed-note.",
238+
)
239+
240+
assert isinstance(result, str)
241+
assert "# Move Failed - File Extension Required" in result
242+
assert "must include a file extension" in result
243+
244+
# Test that note still exists at original location
245+
content = await read_note.fn("source/extension-test", project=test_project.name)
246+
assert "# Extension Test" in content
247+
assert "Testing extension validation" in content
248+
249+
250+
@pytest.mark.asyncio
251+
async def test_move_note_file_extension_mismatch(client, test_project):
252+
"""Test that moving note with different extension is blocked."""
253+
# Create initial note with .md extension
254+
await write_note.fn(
255+
project=test_project.name,
256+
title="MarkdownNote",
257+
folder="source",
258+
content="# Markdown Note\nThis is a markdown file.",
259+
)
260+
261+
# Try to move with .txt extension
262+
result = await move_note.fn(
263+
project=test_project.name,
264+
identifier="source/markdown-note",
265+
destination_path="target/renamed-note.txt",
266+
)
267+
268+
# Should return error about extension mismatch
269+
assert isinstance(result, str)
270+
assert "# Move Failed - File Extension Mismatch" in result
271+
assert "does not match the source file extension" in result
272+
assert ".md" in result
273+
assert ".txt" in result
274+
assert "renamed-note.md" in result # Should suggest correct extension
275+
276+
# Test that note still exists at original location with original extension
277+
content = await read_note.fn("source/markdown-note", project=test_project.name)
278+
assert "# Markdown Note" in content
279+
assert "This is a markdown file" in content
280+
281+
282+
@pytest.mark.asyncio
283+
async def test_move_note_preserves_file_extension(client, test_project):
284+
"""Test that moving note with matching extension succeeds."""
285+
# Create initial note with .md extension
286+
await write_note.fn(
287+
project=test_project.name,
288+
title="PreserveExtension",
289+
folder="source",
290+
content="# Preserve Extension\nTesting that extension is preserved.",
291+
)
292+
293+
# Move with same .md extension
294+
result = await move_note.fn(
295+
project=test_project.name,
296+
identifier="source/preserve-extension",
297+
destination_path="target/preserved-note.md",
298+
)
299+
300+
# Should succeed
301+
assert isinstance(result, str)
302+
assert "✅ Note moved successfully" in result
303+
304+
# Verify note exists at new location with same extension
305+
content = await read_note.fn("target/preserved-note", project=test_project.name)
306+
assert "# Preserve Extension" in content
307+
assert "Testing that extension is preserved" in content
308+
309+
# Verify old location no longer exists
310+
try:
311+
await read_note.fn("source/preserve-extension")
312+
assert False, "Original note should not exist after move"
313+
except Exception:
314+
pass # Expected
315+
316+
208317
@pytest.mark.asyncio
209318
async def test_move_note_destination_exists(client, test_project):
210319
"""Test moving note to existing destination."""

0 commit comments

Comments
 (0)