basic-memory 0.15.0__py3-none-any.whl → 0.15.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +1 -1
- basic_memory/api/routers/directory_router.py +23 -2
- basic_memory/api/routers/project_router.py +1 -0
- basic_memory/cli/auth.py +2 -2
- basic_memory/cli/commands/command_utils.py +11 -28
- basic_memory/cli/commands/mcp.py +72 -67
- basic_memory/cli/commands/project.py +54 -49
- basic_memory/cli/commands/status.py +6 -15
- basic_memory/config.py +55 -9
- basic_memory/deps.py +7 -5
- basic_memory/ignore_utils.py +7 -7
- basic_memory/mcp/async_client.py +102 -4
- basic_memory/mcp/prompts/continue_conversation.py +16 -15
- basic_memory/mcp/prompts/search.py +12 -11
- basic_memory/mcp/resources/ai_assistant_guide.md +185 -453
- basic_memory/mcp/resources/project_info.py +9 -7
- basic_memory/mcp/tools/build_context.py +40 -39
- basic_memory/mcp/tools/canvas.py +21 -20
- basic_memory/mcp/tools/chatgpt_tools.py +11 -2
- basic_memory/mcp/tools/delete_note.py +22 -21
- basic_memory/mcp/tools/edit_note.py +105 -104
- basic_memory/mcp/tools/list_directory.py +98 -95
- basic_memory/mcp/tools/move_note.py +127 -125
- basic_memory/mcp/tools/project_management.py +101 -98
- basic_memory/mcp/tools/read_content.py +64 -63
- basic_memory/mcp/tools/read_note.py +88 -88
- basic_memory/mcp/tools/recent_activity.py +139 -135
- basic_memory/mcp/tools/search.py +27 -26
- basic_memory/mcp/tools/sync_status.py +133 -128
- basic_memory/mcp/tools/utils.py +0 -15
- basic_memory/mcp/tools/view_note.py +14 -28
- basic_memory/mcp/tools/write_note.py +97 -87
- basic_memory/repository/entity_repository.py +60 -0
- basic_memory/repository/repository.py +16 -3
- basic_memory/repository/search_repository.py +42 -0
- basic_memory/schemas/project_info.py +1 -1
- basic_memory/services/directory_service.py +124 -3
- basic_memory/services/entity_service.py +31 -9
- basic_memory/services/project_service.py +97 -10
- basic_memory/services/search_service.py +16 -8
- basic_memory/sync/sync_service.py +28 -13
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/METADATA +51 -4
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/RECORD +46 -47
- basic_memory/mcp/tools/headers.py +0 -44
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,7 +5,7 @@ from typing import Optional
|
|
|
5
5
|
from loguru import logger
|
|
6
6
|
from fastmcp import Context
|
|
7
7
|
|
|
8
|
-
from basic_memory.mcp.async_client import
|
|
8
|
+
from basic_memory.mcp.async_client import get_client
|
|
9
9
|
from basic_memory.mcp.project_context import get_active_project
|
|
10
10
|
from basic_memory.mcp.server import mcp
|
|
11
11
|
from basic_memory.mcp.tools.utils import call_get
|
|
@@ -63,102 +63,105 @@ async def list_directory(
|
|
|
63
63
|
Raises:
|
|
64
64
|
ToolError: If project doesn't exist or directory path is invalid
|
|
65
65
|
"""
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
params["file_name_glob"] = file_name_glob
|
|
76
|
-
|
|
77
|
-
logger.debug(
|
|
78
|
-
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
# Call the API endpoint
|
|
82
|
-
response = await call_get(
|
|
83
|
-
client,
|
|
84
|
-
f"{project_url}/directory/list",
|
|
85
|
-
params=params,
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
nodes = response.json()
|
|
89
|
-
|
|
90
|
-
if not nodes:
|
|
91
|
-
filter_desc = ""
|
|
66
|
+
async with get_client() as client:
|
|
67
|
+
active_project = await get_active_project(client, project, context)
|
|
68
|
+
project_url = active_project.project_url
|
|
69
|
+
|
|
70
|
+
# Prepare query parameters
|
|
71
|
+
params = {
|
|
72
|
+
"dir_name": dir_name,
|
|
73
|
+
"depth": str(depth),
|
|
74
|
+
}
|
|
92
75
|
if file_name_glob:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if file_name_glob:
|
|
99
|
-
output_lines.append(f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):")
|
|
100
|
-
else:
|
|
101
|
-
output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
|
|
102
|
-
output_lines.append("")
|
|
103
|
-
|
|
104
|
-
# Group by type and sort
|
|
105
|
-
directories = [n for n in nodes if n["type"] == "directory"]
|
|
106
|
-
files = [n for n in nodes if n["type"] == "file"]
|
|
107
|
-
|
|
108
|
-
# Sort by name
|
|
109
|
-
directories.sort(key=lambda x: x["name"])
|
|
110
|
-
files.sort(key=lambda x: x["name"])
|
|
111
|
-
|
|
112
|
-
# Display directories first
|
|
113
|
-
for node in directories:
|
|
114
|
-
path_display = node["directory_path"]
|
|
115
|
-
output_lines.append(f"📁 {node['name']:<30} {path_display}")
|
|
116
|
-
|
|
117
|
-
# Add separator if we have both directories and files
|
|
118
|
-
if directories and files:
|
|
119
|
-
output_lines.append("")
|
|
76
|
+
params["file_name_glob"] = file_name_glob
|
|
77
|
+
|
|
78
|
+
logger.debug(
|
|
79
|
+
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
|
|
80
|
+
)
|
|
120
81
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
# Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
|
|
128
|
-
if path_display.startswith("/"):
|
|
129
|
-
path_display = path_display[1:]
|
|
130
|
-
|
|
131
|
-
# Format date if available
|
|
132
|
-
date_str = ""
|
|
133
|
-
if updated:
|
|
134
|
-
try:
|
|
135
|
-
from datetime import datetime
|
|
136
|
-
|
|
137
|
-
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
|
138
|
-
date_str = dt.strftime("%Y-%m-%d")
|
|
139
|
-
except Exception: # pragma: no cover
|
|
140
|
-
date_str = updated[:10] if len(updated) >= 10 else ""
|
|
141
|
-
|
|
142
|
-
# Create formatted line
|
|
143
|
-
file_line = f"📄 {node['name']:<30} {path_display}"
|
|
144
|
-
if title and title != node["name"]:
|
|
145
|
-
file_line += f" | {title}"
|
|
146
|
-
if date_str:
|
|
147
|
-
file_line += f" | {date_str}"
|
|
148
|
-
|
|
149
|
-
output_lines.append(file_line)
|
|
150
|
-
|
|
151
|
-
# Add summary
|
|
152
|
-
output_lines.append("")
|
|
153
|
-
total_count = len(directories) + len(files)
|
|
154
|
-
summary_parts = []
|
|
155
|
-
if directories:
|
|
156
|
-
summary_parts.append(
|
|
157
|
-
f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
|
|
82
|
+
# Call the API endpoint
|
|
83
|
+
response = await call_get(
|
|
84
|
+
client,
|
|
85
|
+
f"{project_url}/directory/list",
|
|
86
|
+
params=params,
|
|
158
87
|
)
|
|
159
|
-
if files:
|
|
160
|
-
summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
|
|
161
88
|
|
|
162
|
-
|
|
89
|
+
nodes = response.json()
|
|
90
|
+
|
|
91
|
+
if not nodes:
|
|
92
|
+
filter_desc = ""
|
|
93
|
+
if file_name_glob:
|
|
94
|
+
filter_desc = f" matching '{file_name_glob}'"
|
|
95
|
+
return f"No files found in directory '{dir_name}'{filter_desc}"
|
|
163
96
|
|
|
164
|
-
|
|
97
|
+
# Format the results
|
|
98
|
+
output_lines = []
|
|
99
|
+
if file_name_glob:
|
|
100
|
+
output_lines.append(
|
|
101
|
+
f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):"
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
|
|
105
|
+
output_lines.append("")
|
|
106
|
+
|
|
107
|
+
# Group by type and sort
|
|
108
|
+
directories = [n for n in nodes if n["type"] == "directory"]
|
|
109
|
+
files = [n for n in nodes if n["type"] == "file"]
|
|
110
|
+
|
|
111
|
+
# Sort by name
|
|
112
|
+
directories.sort(key=lambda x: x["name"])
|
|
113
|
+
files.sort(key=lambda x: x["name"])
|
|
114
|
+
|
|
115
|
+
# Display directories first
|
|
116
|
+
for node in directories:
|
|
117
|
+
path_display = node["directory_path"]
|
|
118
|
+
output_lines.append(f"📁 {node['name']:<30} {path_display}")
|
|
119
|
+
|
|
120
|
+
# Add separator if we have both directories and files
|
|
121
|
+
if directories and files:
|
|
122
|
+
output_lines.append("")
|
|
123
|
+
|
|
124
|
+
# Display files with metadata
|
|
125
|
+
for node in files:
|
|
126
|
+
path_display = node["directory_path"]
|
|
127
|
+
title = node.get("title", "")
|
|
128
|
+
updated = node.get("updated_at", "")
|
|
129
|
+
|
|
130
|
+
# Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
|
|
131
|
+
if path_display.startswith("/"):
|
|
132
|
+
path_display = path_display[1:]
|
|
133
|
+
|
|
134
|
+
# Format date if available
|
|
135
|
+
date_str = ""
|
|
136
|
+
if updated:
|
|
137
|
+
try:
|
|
138
|
+
from datetime import datetime
|
|
139
|
+
|
|
140
|
+
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
|
141
|
+
date_str = dt.strftime("%Y-%m-%d")
|
|
142
|
+
except Exception: # pragma: no cover
|
|
143
|
+
date_str = updated[:10] if len(updated) >= 10 else ""
|
|
144
|
+
|
|
145
|
+
# Create formatted line
|
|
146
|
+
file_line = f"📄 {node['name']:<30} {path_display}"
|
|
147
|
+
if title and title != node["name"]:
|
|
148
|
+
file_line += f" | {title}"
|
|
149
|
+
if date_str:
|
|
150
|
+
file_line += f" | {date_str}"
|
|
151
|
+
|
|
152
|
+
output_lines.append(file_line)
|
|
153
|
+
|
|
154
|
+
# Add summary
|
|
155
|
+
output_lines.append("")
|
|
156
|
+
total_count = len(directories) + len(files)
|
|
157
|
+
summary_parts = []
|
|
158
|
+
if directories:
|
|
159
|
+
summary_parts.append(
|
|
160
|
+
f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
|
|
161
|
+
)
|
|
162
|
+
if files:
|
|
163
|
+
summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
|
|
164
|
+
|
|
165
|
+
output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
|
|
166
|
+
|
|
167
|
+
return "\n".join(output_lines)
|
|
@@ -6,7 +6,7 @@ from typing import Optional
|
|
|
6
6
|
from loguru import logger
|
|
7
7
|
from fastmcp import Context
|
|
8
8
|
|
|
9
|
-
from basic_memory.mcp.async_client import
|
|
9
|
+
from basic_memory.mcp.async_client import get_client
|
|
10
10
|
from basic_memory.mcp.server import mcp
|
|
11
11
|
from basic_memory.mcp.tools.utils import call_post, call_get
|
|
12
12
|
from basic_memory.mcp.project_context import get_active_project
|
|
@@ -16,11 +16,12 @@ from basic_memory.utils import validate_project_path
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
async def _detect_cross_project_move_attempt(
|
|
19
|
-
identifier: str, destination_path: str, current_project: str
|
|
19
|
+
client, identifier: str, destination_path: str, current_project: str
|
|
20
20
|
) -> Optional[str]:
|
|
21
21
|
"""Detect potential cross-project move attempts and return guidance.
|
|
22
22
|
|
|
23
23
|
Args:
|
|
24
|
+
client: The AsyncClient instance
|
|
24
25
|
identifier: The note identifier being moved
|
|
25
26
|
destination_path: The destination path
|
|
26
27
|
current_project: The current active project
|
|
@@ -394,20 +395,21 @@ async def move_note(
|
|
|
394
395
|
- Re-indexes the entity for search
|
|
395
396
|
- Maintains all observations and relations
|
|
396
397
|
"""
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
398
|
+
async with get_client() as client:
|
|
399
|
+
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
|
|
400
|
+
|
|
401
|
+
active_project = await get_active_project(client, project, context)
|
|
402
|
+
project_url = active_project.project_url
|
|
403
|
+
|
|
404
|
+
# Validate destination path to prevent path traversal attacks
|
|
405
|
+
project_path = active_project.home
|
|
406
|
+
if not validate_project_path(destination_path, project_path):
|
|
407
|
+
logger.warning(
|
|
408
|
+
"Attempted path traversal attack blocked",
|
|
409
|
+
destination_path=destination_path,
|
|
410
|
+
project=active_project.name,
|
|
411
|
+
)
|
|
412
|
+
return f"""# Move Failed - Security Validation Error
|
|
411
413
|
|
|
412
414
|
The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
|
|
413
415
|
|
|
@@ -421,123 +423,123 @@ The destination path '{destination_path}' is not allowed - paths must stay withi
|
|
|
421
423
|
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
|
|
422
424
|
```"""
|
|
423
425
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
)
|
|
428
|
-
if cross_project_error:
|
|
429
|
-
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
|
|
430
|
-
return cross_project_error
|
|
431
|
-
|
|
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 ""
|
|
426
|
+
# Check for potential cross-project move attempts
|
|
427
|
+
cross_project_error = await _detect_cross_project_move_attempt(
|
|
428
|
+
client, identifier, destination_path, active_project.name
|
|
476
429
|
)
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
430
|
+
if cross_project_error:
|
|
431
|
+
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
|
|
432
|
+
return cross_project_error
|
|
433
|
+
|
|
434
|
+
# Get the source entity information for extension validation
|
|
435
|
+
source_ext = "md" # Default to .md if we can't determine source extension
|
|
436
|
+
try:
|
|
437
|
+
# Fetch source entity information to get the current file extension
|
|
438
|
+
url = f"{project_url}/knowledge/entities/{identifier}"
|
|
439
|
+
response = await call_get(client, url)
|
|
440
|
+
source_entity = EntityResponse.model_validate(response.json())
|
|
441
|
+
if "." in source_entity.file_path:
|
|
442
|
+
source_ext = source_entity.file_path.split(".")[-1]
|
|
443
|
+
except Exception as e:
|
|
444
|
+
# If we can't fetch the source entity, default to .md extension
|
|
445
|
+
logger.debug(f"Could not fetch source entity for extension check: {e}")
|
|
446
|
+
|
|
447
|
+
# Validate that destination path includes a file extension
|
|
448
|
+
if "." not in destination_path or not destination_path.split(".")[-1]:
|
|
449
|
+
logger.warning(f"Move failed - no file extension provided: {destination_path}")
|
|
484
450
|
return dedent(f"""
|
|
485
|
-
# Move Failed - File Extension
|
|
486
|
-
|
|
487
|
-
The destination file extension '.{dest_ext}' does not match the source file extension '.{source_ext}'.
|
|
451
|
+
# Move Failed - File Extension Required
|
|
488
452
|
|
|
489
|
-
|
|
453
|
+
The destination path '{destination_path}' must include a file extension (e.g., '.md').
|
|
490
454
|
|
|
491
|
-
##
|
|
492
|
-
-
|
|
493
|
-
-
|
|
455
|
+
## Valid examples:
|
|
456
|
+
- `notes/my-note.md`
|
|
457
|
+
- `projects/meeting-2025.txt`
|
|
458
|
+
- `archive/old-program.sh`
|
|
494
459
|
|
|
495
|
-
## Try again with
|
|
460
|
+
## Try again with extension:
|
|
496
461
|
```
|
|
497
|
-
move_note("{identifier}", "{destination_path
|
|
462
|
+
move_note("{identifier}", "{destination_path}.{source_ext}")
|
|
498
463
|
```
|
|
464
|
+
|
|
465
|
+
All examples in Basic Memory expect file extensions to be explicitly provided.
|
|
499
466
|
""").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
467
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
"identifier"
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
# Call the move API endpoint
|
|
514
|
-
url = f"{project_url}/knowledge/move"
|
|
515
|
-
response = await call_post(client, url, json=move_data)
|
|
516
|
-
result = EntityResponse.model_validate(response.json())
|
|
517
|
-
|
|
518
|
-
# Build success message
|
|
519
|
-
result_lines = [
|
|
520
|
-
"✅ Note moved successfully",
|
|
521
|
-
"",
|
|
522
|
-
f"📁 **{identifier}** → **{result.file_path}**",
|
|
523
|
-
f"🔗 Permalink: {result.permalink}",
|
|
524
|
-
"📊 Database and search index updated",
|
|
525
|
-
"",
|
|
526
|
-
f"<!-- Project: {active_project.name} -->",
|
|
527
|
-
]
|
|
528
|
-
|
|
529
|
-
# Log the operation
|
|
530
|
-
logger.info(
|
|
531
|
-
"Move note completed",
|
|
532
|
-
identifier=identifier,
|
|
533
|
-
destination_path=destination_path,
|
|
534
|
-
project=active_project.name,
|
|
535
|
-
status_code=response.status_code,
|
|
536
|
-
)
|
|
468
|
+
# Get the source entity to check its file extension
|
|
469
|
+
try:
|
|
470
|
+
# Fetch source entity information
|
|
471
|
+
url = f"{project_url}/knowledge/entities/{identifier}"
|
|
472
|
+
response = await call_get(client, url)
|
|
473
|
+
source_entity = EntityResponse.model_validate(response.json())
|
|
537
474
|
|
|
538
|
-
|
|
475
|
+
# Extract file extensions
|
|
476
|
+
source_ext = (
|
|
477
|
+
source_entity.file_path.split(".")[-1] if "." in source_entity.file_path else ""
|
|
478
|
+
)
|
|
479
|
+
dest_ext = destination_path.split(".")[-1] if "." in destination_path else ""
|
|
539
480
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
481
|
+
# Check if extensions match
|
|
482
|
+
if source_ext and dest_ext and source_ext.lower() != dest_ext.lower():
|
|
483
|
+
logger.warning(
|
|
484
|
+
f"Move failed - file extension mismatch: source={source_ext}, dest={dest_ext}"
|
|
485
|
+
)
|
|
486
|
+
return dedent(f"""
|
|
487
|
+
# Move Failed - File Extension Mismatch
|
|
488
|
+
|
|
489
|
+
The destination file extension '.{dest_ext}' does not match the source file extension '.{source_ext}'.
|
|
490
|
+
|
|
491
|
+
To preserve file type consistency, the destination must have the same extension as the source.
|
|
492
|
+
|
|
493
|
+
## Source file:
|
|
494
|
+
- Path: `{source_entity.file_path}`
|
|
495
|
+
- Extension: `.{source_ext}`
|
|
496
|
+
|
|
497
|
+
## Try again with matching extension:
|
|
498
|
+
```
|
|
499
|
+
move_note("{identifier}", "{destination_path.rsplit(".", 1)[0]}.{source_ext}")
|
|
500
|
+
```
|
|
501
|
+
""").strip()
|
|
502
|
+
except Exception as e:
|
|
503
|
+
# If we can't fetch the source entity, log it but continue
|
|
504
|
+
# This might happen if the identifier is not yet resolved
|
|
505
|
+
logger.debug(f"Could not fetch source entity for extension check: {e}")
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
# Prepare move request
|
|
509
|
+
move_data = {
|
|
510
|
+
"identifier": identifier,
|
|
511
|
+
"destination_path": destination_path,
|
|
512
|
+
"project": active_project.name,
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
# Call the move API endpoint
|
|
516
|
+
url = f"{project_url}/knowledge/move"
|
|
517
|
+
response = await call_post(client, url, json=move_data)
|
|
518
|
+
result = EntityResponse.model_validate(response.json())
|
|
519
|
+
|
|
520
|
+
# Build success message
|
|
521
|
+
result_lines = [
|
|
522
|
+
"✅ Note moved successfully",
|
|
523
|
+
"",
|
|
524
|
+
f"📁 **{identifier}** → **{result.file_path}**",
|
|
525
|
+
f"🔗 Permalink: {result.permalink}",
|
|
526
|
+
"📊 Database and search index updated",
|
|
527
|
+
"",
|
|
528
|
+
f"<!-- Project: {active_project.name} -->",
|
|
529
|
+
]
|
|
530
|
+
|
|
531
|
+
# Log the operation
|
|
532
|
+
logger.info(
|
|
533
|
+
"Move note completed",
|
|
534
|
+
identifier=identifier,
|
|
535
|
+
destination_path=destination_path,
|
|
536
|
+
project=active_project.name,
|
|
537
|
+
status_code=response.status_code,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
return "\n".join(result_lines)
|
|
541
|
+
|
|
542
|
+
except Exception as e:
|
|
543
|
+
logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
|
|
544
|
+
# Return formatted error message for better user experience
|
|
545
|
+
return _format_move_error_response(str(e), identifier, destination_path)
|