basic-memory 0.14.4__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/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
- basic_memory/api/app.py +10 -4
- basic_memory/api/routers/directory_router.py +23 -2
- basic_memory/api/routers/knowledge_router.py +25 -8
- basic_memory/api/routers/project_router.py +100 -4
- basic_memory/cli/app.py +9 -28
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/cloud/__init__.py +5 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
- basic_memory/cli/commands/cloud/core_commands.py +288 -0
- basic_memory/cli/commands/cloud/mount_commands.py +295 -0
- basic_memory/cli/commands/cloud/rclone_config.py +288 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
- basic_memory/cli/commands/command_utils.py +43 -0
- basic_memory/cli/commands/import_memory_json.py +0 -4
- basic_memory/cli/commands/mcp.py +77 -60
- basic_memory/cli/commands/project.py +154 -152
- basic_memory/cli/commands/status.py +25 -22
- basic_memory/cli/commands/sync.py +45 -228
- basic_memory/cli/commands/tool.py +87 -16
- basic_memory/cli/main.py +1 -0
- basic_memory/config.py +131 -21
- basic_memory/db.py +104 -3
- basic_memory/deps.py +27 -8
- basic_memory/file_utils.py +37 -13
- basic_memory/ignore_utils.py +295 -0
- basic_memory/markdown/plugins.py +9 -7
- basic_memory/mcp/async_client.py +124 -14
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
- basic_memory/mcp/prompts/continue_conversation.py +17 -16
- basic_memory/mcp/prompts/recent_activity.py +116 -32
- basic_memory/mcp/prompts/search.py +13 -12
- basic_memory/mcp/prompts/utils.py +11 -4
- basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
- basic_memory/mcp/resources/project_info.py +27 -11
- basic_memory/mcp/server.py +0 -37
- basic_memory/mcp/tools/__init__.py +5 -6
- basic_memory/mcp/tools/build_context.py +67 -56
- basic_memory/mcp/tools/canvas.py +38 -26
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +81 -47
- basic_memory/mcp/tools/edit_note.py +155 -138
- basic_memory/mcp/tools/list_directory.py +112 -99
- basic_memory/mcp/tools/move_note.py +181 -101
- basic_memory/mcp/tools/project_management.py +113 -277
- basic_memory/mcp/tools/read_content.py +91 -74
- basic_memory/mcp/tools/read_note.py +152 -115
- basic_memory/mcp/tools/recent_activity.py +471 -68
- basic_memory/mcp/tools/search.py +105 -92
- basic_memory/mcp/tools/sync_status.py +136 -130
- basic_memory/mcp/tools/utils.py +4 -0
- basic_memory/mcp/tools/view_note.py +44 -33
- basic_memory/mcp/tools/write_note.py +151 -90
- basic_memory/models/knowledge.py +12 -6
- basic_memory/models/project.py +6 -2
- basic_memory/repository/entity_repository.py +89 -82
- basic_memory/repository/relation_repository.py +13 -0
- basic_memory/repository/repository.py +18 -5
- basic_memory/repository/search_repository.py +46 -2
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +39 -11
- basic_memory/schemas/cloud.py +46 -0
- basic_memory/schemas/memory.py +90 -21
- basic_memory/schemas/project_info.py +9 -10
- basic_memory/schemas/sync_report.py +48 -0
- basic_memory/services/context_service.py +25 -11
- basic_memory/services/directory_service.py +124 -3
- basic_memory/services/entity_service.py +100 -48
- basic_memory/services/initialization.py +30 -11
- basic_memory/services/project_service.py +101 -24
- basic_memory/services/search_service.py +16 -8
- basic_memory/sync/sync_service.py +173 -34
- basic_memory/sync/watch_service.py +101 -40
- basic_memory/utils.py +14 -4
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
- basic_memory-0.15.1.dist-info/RECORD +146 -0
- basic_memory/mcp/project_session.py +0 -120
- basic_memory-0.14.4.dist-info/RECORD +0 -133
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
from typing import Optional
|
|
4
4
|
|
|
5
5
|
from loguru import logger
|
|
6
|
+
from fastmcp import Context
|
|
6
7
|
|
|
7
|
-
from basic_memory.mcp.async_client import
|
|
8
|
-
from basic_memory.mcp.
|
|
8
|
+
from basic_memory.mcp.async_client import get_client
|
|
9
|
+
from basic_memory.mcp.project_context import get_active_project
|
|
9
10
|
from basic_memory.mcp.server import mcp
|
|
10
11
|
from basic_memory.mcp.tools.utils import call_get
|
|
11
12
|
|
|
@@ -18,6 +19,7 @@ async def list_directory(
|
|
|
18
19
|
depth: int = 1,
|
|
19
20
|
file_name_glob: Optional[str] = None,
|
|
20
21
|
project: Optional[str] = None,
|
|
22
|
+
context: Context | None = None,
|
|
21
23
|
) -> str:
|
|
22
24
|
"""List directory contents from the knowledge base with optional filtering.
|
|
23
25
|
|
|
@@ -32,7 +34,10 @@ async def list_directory(
|
|
|
32
34
|
Higher values show subdirectory contents recursively
|
|
33
35
|
file_name_glob: Optional glob pattern for filtering file names
|
|
34
36
|
Examples: "*.md", "*meeting*", "project_*"
|
|
35
|
-
project:
|
|
37
|
+
project: Project name to list directory from. Optional - server will resolve using hierarchy.
|
|
38
|
+
If unknown, use list_memory_projects() to discover available projects.
|
|
39
|
+
context: Optional FastMCP context for performance caching.
|
|
40
|
+
|
|
36
41
|
Returns:
|
|
37
42
|
Formatted listing of directory contents with file metadata
|
|
38
43
|
|
|
@@ -43,8 +48,8 @@ async def list_directory(
|
|
|
43
48
|
# List specific folder
|
|
44
49
|
list_directory(dir_name="/projects")
|
|
45
50
|
|
|
46
|
-
# Find all
|
|
47
|
-
list_directory(file_name_glob="*.
|
|
51
|
+
# Find all markdown files
|
|
52
|
+
list_directory(file_name_glob="*.md")
|
|
48
53
|
|
|
49
54
|
# Deep exploration of research folder
|
|
50
55
|
list_directory(dir_name="/research", depth=3)
|
|
@@ -52,103 +57,111 @@ async def list_directory(
|
|
|
52
57
|
# Find meeting notes in projects folder
|
|
53
58
|
list_directory(dir_name="/projects", file_name_glob="*meeting*")
|
|
54
59
|
|
|
55
|
-
#
|
|
56
|
-
list_directory(
|
|
60
|
+
# Explicit project specification
|
|
61
|
+
list_directory(project="work-docs", dir_name="/projects")
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ToolError: If project doesn't exist or directory path is invalid
|
|
57
65
|
"""
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
params["file_name_glob"] = file_name_glob
|
|
68
|
-
|
|
69
|
-
logger.debug(f"Listing directory '{dir_name}' with depth={depth}, glob='{file_name_glob}'")
|
|
70
|
-
|
|
71
|
-
# Call the API endpoint
|
|
72
|
-
response = await call_get(
|
|
73
|
-
client,
|
|
74
|
-
f"{project_url}/directory/list",
|
|
75
|
-
params=params,
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
nodes = response.json()
|
|
79
|
-
|
|
80
|
-
if not nodes:
|
|
81
|
-
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
|
+
}
|
|
82
75
|
if file_name_glob:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if file_name_glob:
|
|
89
|
-
output_lines.append(f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):")
|
|
90
|
-
else:
|
|
91
|
-
output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
|
|
92
|
-
output_lines.append("")
|
|
93
|
-
|
|
94
|
-
# Group by type and sort
|
|
95
|
-
directories = [n for n in nodes if n["type"] == "directory"]
|
|
96
|
-
files = [n for n in nodes if n["type"] == "file"]
|
|
97
|
-
|
|
98
|
-
# Sort by name
|
|
99
|
-
directories.sort(key=lambda x: x["name"])
|
|
100
|
-
files.sort(key=lambda x: x["name"])
|
|
101
|
-
|
|
102
|
-
# Display directories first
|
|
103
|
-
for node in directories:
|
|
104
|
-
path_display = node["directory_path"]
|
|
105
|
-
output_lines.append(f"📁 {node['name']:<30} {path_display}")
|
|
106
|
-
|
|
107
|
-
# Add separator if we have both directories and files
|
|
108
|
-
if directories and files:
|
|
109
|
-
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
|
+
)
|
|
110
81
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
# Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
|
|
118
|
-
if path_display.startswith("/"):
|
|
119
|
-
path_display = path_display[1:]
|
|
120
|
-
|
|
121
|
-
# Format date if available
|
|
122
|
-
date_str = ""
|
|
123
|
-
if updated:
|
|
124
|
-
try:
|
|
125
|
-
from datetime import datetime
|
|
126
|
-
|
|
127
|
-
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
|
128
|
-
date_str = dt.strftime("%Y-%m-%d")
|
|
129
|
-
except Exception: # pragma: no cover
|
|
130
|
-
date_str = updated[:10] if len(updated) >= 10 else ""
|
|
131
|
-
|
|
132
|
-
# Create formatted line
|
|
133
|
-
file_line = f"📄 {node['name']:<30} {path_display}"
|
|
134
|
-
if title and title != node["name"]:
|
|
135
|
-
file_line += f" | {title}"
|
|
136
|
-
if date_str:
|
|
137
|
-
file_line += f" | {date_str}"
|
|
138
|
-
|
|
139
|
-
output_lines.append(file_line)
|
|
140
|
-
|
|
141
|
-
# Add summary
|
|
142
|
-
output_lines.append("")
|
|
143
|
-
total_count = len(directories) + len(files)
|
|
144
|
-
summary_parts = []
|
|
145
|
-
if directories:
|
|
146
|
-
summary_parts.append(
|
|
147
|
-
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,
|
|
148
87
|
)
|
|
149
|
-
if files:
|
|
150
|
-
summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
|
|
151
88
|
|
|
152
|
-
|
|
89
|
+
nodes = response.json()
|
|
153
90
|
|
|
154
|
-
|
|
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}"
|
|
96
|
+
|
|
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)
|
|
@@ -4,22 +4,24 @@ from textwrap import dedent
|
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
6
|
from loguru import logger
|
|
7
|
+
from fastmcp import Context
|
|
7
8
|
|
|
8
|
-
from basic_memory.mcp.async_client import
|
|
9
|
+
from basic_memory.mcp.async_client import get_client
|
|
9
10
|
from basic_memory.mcp.server import mcp
|
|
10
11
|
from basic_memory.mcp.tools.utils import call_post, call_get
|
|
11
|
-
from basic_memory.mcp.
|
|
12
|
+
from basic_memory.mcp.project_context import get_active_project
|
|
12
13
|
from basic_memory.schemas import EntityResponse
|
|
13
14
|
from basic_memory.schemas.project_info import ProjectList
|
|
14
15
|
from basic_memory.utils import validate_project_path
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
async def _detect_cross_project_move_attempt(
|
|
18
|
-
identifier: str, destination_path: str, current_project: str
|
|
19
|
+
client, identifier: str, destination_path: str, current_project: str
|
|
19
20
|
) -> Optional[str]:
|
|
20
21
|
"""Detect potential cross-project move attempts and return guidance.
|
|
21
22
|
|
|
22
23
|
Args:
|
|
24
|
+
client: The AsyncClient instance
|
|
23
25
|
identifier: The note identifier being moved
|
|
24
26
|
destination_path: The destination path
|
|
25
27
|
current_project: The current active project
|
|
@@ -64,42 +66,37 @@ def _format_cross_project_error_response(
|
|
|
64
66
|
"""Format error response for detected cross-project move attempts."""
|
|
65
67
|
return dedent(f"""
|
|
66
68
|
# Move Failed - Cross-Project Move Not Supported
|
|
67
|
-
|
|
69
|
+
|
|
68
70
|
Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}').
|
|
69
|
-
|
|
71
|
+
|
|
70
72
|
**Current project:** {current_project}
|
|
71
73
|
**Target project:** {target_project}
|
|
72
|
-
|
|
74
|
+
|
|
73
75
|
## Cross-project moves are not supported directly
|
|
74
|
-
|
|
76
|
+
|
|
75
77
|
Notes can only be moved within the same project. To move content between projects, use this workflow:
|
|
76
|
-
|
|
78
|
+
|
|
77
79
|
### Recommended approach:
|
|
78
80
|
```
|
|
79
81
|
# 1. Read the note content from current project
|
|
80
82
|
read_note("{identifier}")
|
|
81
83
|
|
|
82
|
-
# 2.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
# 3.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
# 4. Switch back to original project (optional)
|
|
89
|
-
switch_project("{current_project}")
|
|
84
|
+
# 2. Create the note in the target project
|
|
85
|
+
write_note("Note Title", "content from step 1", "target-folder", project="{target_project}")
|
|
86
|
+
|
|
87
|
+
# 3. Delete the original note if desired
|
|
88
|
+
delete_note("{identifier}", project="{current_project}")
|
|
90
89
|
|
|
91
|
-
# 5. Delete the original note if desired
|
|
92
|
-
delete_note("{identifier}")
|
|
93
90
|
```
|
|
94
|
-
|
|
91
|
+
|
|
95
92
|
### Alternative: Stay in current project
|
|
96
93
|
If you want to move the note within the **{current_project}** project only:
|
|
97
94
|
```
|
|
98
95
|
move_note("{identifier}", "new-folder/new-name.md")
|
|
99
96
|
```
|
|
100
|
-
|
|
97
|
+
|
|
101
98
|
## Available projects:
|
|
102
|
-
Use `
|
|
99
|
+
Use `list_memory_projects()` to see all available projects.
|
|
103
100
|
""").strip()
|
|
104
101
|
|
|
105
102
|
|
|
@@ -130,20 +127,16 @@ def _format_potential_cross_project_guidance(
|
|
|
130
127
|
# 1. Read the content
|
|
131
128
|
read_note("{identifier}")
|
|
132
129
|
|
|
133
|
-
# 2.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
# 3.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# 4. Switch back and delete original if desired
|
|
140
|
-
switch_project("{current_project}")
|
|
141
|
-
delete_note("{identifier}")
|
|
130
|
+
# 2. Create note in target project
|
|
131
|
+
write_note("Title", "content", "folder", project="target-project-name")
|
|
132
|
+
|
|
133
|
+
# 3. Delete original if desired
|
|
134
|
+
delete_note("{identifier}", project="{current_project}")
|
|
142
135
|
```
|
|
143
136
|
|
|
144
137
|
### To see all projects:
|
|
145
138
|
```
|
|
146
|
-
|
|
139
|
+
list_memory_projects()
|
|
147
140
|
```
|
|
148
141
|
""").strip()
|
|
149
142
|
|
|
@@ -171,7 +164,7 @@ def _format_move_error_response(error_message: str, identifier: str, destination
|
|
|
171
164
|
- If you used a title, try the exact permalink format: "{permalink_format}"
|
|
172
165
|
- Use `read_note()` first to verify the note exists and get the exact identifier
|
|
173
166
|
|
|
174
|
-
3. **
|
|
167
|
+
3. **List available notes**: Use `list_directory("/")` to see what notes exist in the current project
|
|
175
168
|
4. **List available notes**: Use `list_directory("/")` to see what notes exist
|
|
176
169
|
|
|
177
170
|
## Before trying again:
|
|
@@ -248,9 +241,8 @@ You don't have permission to move '{identifier}': {error_message}
|
|
|
248
241
|
3. **Check file locks**: The file might be open in another application
|
|
249
242
|
|
|
250
243
|
## Alternative actions:
|
|
251
|
-
-
|
|
252
|
-
-
|
|
253
|
-
- Try copying content instead: `read_note("{identifier}")` then `write_note()` to new location"""
|
|
244
|
+
- List available projects: `list_memory_projects()`
|
|
245
|
+
- Try copying content instead: `read_note("{identifier}", project="project-name")` then `write_note()` to new location"""
|
|
254
246
|
|
|
255
247
|
# Source file not found errors
|
|
256
248
|
if "source" in error_message.lower() and (
|
|
@@ -352,18 +344,25 @@ async def move_note(
|
|
|
352
344
|
identifier: str,
|
|
353
345
|
destination_path: str,
|
|
354
346
|
project: Optional[str] = None,
|
|
347
|
+
context: Context | None = None,
|
|
355
348
|
) -> str:
|
|
356
349
|
"""Move a note to a new file location within the same project.
|
|
357
350
|
|
|
351
|
+
Moves a note from one location to another within the project, updating all
|
|
352
|
+
database references and maintaining semantic content. Uses stateless architecture -
|
|
353
|
+
project parameter optional with server resolution.
|
|
354
|
+
|
|
358
355
|
Args:
|
|
359
356
|
identifier: Exact entity identifier (title, permalink, or memory:// URL).
|
|
360
357
|
Must be an exact match - fuzzy matching is not supported for move operations.
|
|
361
358
|
Use search_notes() or read_note() first to find the correct identifier if uncertain.
|
|
362
359
|
destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
|
|
363
|
-
project: Optional
|
|
360
|
+
project: Project name to move within. Optional - server will resolve using hierarchy.
|
|
361
|
+
If unknown, use list_memory_projects() to discover available projects.
|
|
362
|
+
context: Optional FastMCP context for performance caching.
|
|
364
363
|
|
|
365
364
|
Returns:
|
|
366
|
-
Success message with move details
|
|
365
|
+
Success message with move details and project information.
|
|
367
366
|
|
|
368
367
|
Examples:
|
|
369
368
|
# Move to new folder (exact title match)
|
|
@@ -372,15 +371,22 @@ async def move_note(
|
|
|
372
371
|
# Move by exact permalink
|
|
373
372
|
move_note("my-note-permalink", "archive/old-notes/my-note.md")
|
|
374
373
|
|
|
375
|
-
#
|
|
376
|
-
move_note("
|
|
374
|
+
# Move with complex path structure
|
|
375
|
+
move_note("experiments/ml-results", "archive/2025/ml-experiments.md")
|
|
376
|
+
|
|
377
|
+
# Explicit project specification
|
|
378
|
+
move_note("My Note", "work/notes/my-note.md", project="work-project")
|
|
377
379
|
|
|
378
380
|
# If uncertain about identifier, search first:
|
|
379
381
|
# search_notes("my note") # Find available notes
|
|
380
382
|
# move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result
|
|
381
383
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
+
Raises:
|
|
385
|
+
ToolError: If project doesn't exist, identifier is not found, or destination_path is invalid
|
|
386
|
+
|
|
387
|
+
Note:
|
|
388
|
+
This operation moves notes within the specified project only. Moving notes
|
|
389
|
+
between different projects is not currently supported.
|
|
384
390
|
|
|
385
391
|
The move operation:
|
|
386
392
|
- Updates the entity's file_path in the database
|
|
@@ -389,20 +395,21 @@ async def move_note(
|
|
|
389
395
|
- Re-indexes the entity for search
|
|
390
396
|
- Maintains all observations and relations
|
|
391
397
|
"""
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
406
413
|
|
|
407
414
|
The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
|
|
408
415
|
|
|
@@ -416,50 +423,123 @@ The destination path '{destination_path}' is not allowed - paths must stay withi
|
|
|
416
423
|
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
|
|
417
424
|
```"""
|
|
418
425
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
)
|
|
423
|
-
if cross_project_error:
|
|
424
|
-
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
|
|
425
|
-
return cross_project_error
|
|
426
|
-
|
|
427
|
-
try:
|
|
428
|
-
# Prepare move request
|
|
429
|
-
move_data = {
|
|
430
|
-
"identifier": identifier,
|
|
431
|
-
"destination_path": destination_path,
|
|
432
|
-
"project": active_project.name,
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
# Call the move API endpoint
|
|
436
|
-
url = f"{project_url}/knowledge/move"
|
|
437
|
-
response = await call_post(client, url, json=move_data)
|
|
438
|
-
result = EntityResponse.model_validate(response.json())
|
|
439
|
-
|
|
440
|
-
# Build success message
|
|
441
|
-
result_lines = [
|
|
442
|
-
"✅ Note moved successfully",
|
|
443
|
-
"",
|
|
444
|
-
f"📁 **{identifier}** → **{result.file_path}**",
|
|
445
|
-
f"🔗 Permalink: {result.permalink}",
|
|
446
|
-
"📊 Database and search index updated",
|
|
447
|
-
"",
|
|
448
|
-
f"<!-- Project: {active_project.name} -->",
|
|
449
|
-
]
|
|
450
|
-
|
|
451
|
-
# Log the operation
|
|
452
|
-
logger.info(
|
|
453
|
-
"Move note completed",
|
|
454
|
-
identifier=identifier,
|
|
455
|
-
destination_path=destination_path,
|
|
456
|
-
project=active_project.name,
|
|
457
|
-
status_code=response.status_code,
|
|
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
|
|
458
429
|
)
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
#
|
|
465
|
-
|
|
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}")
|
|
450
|
+
return dedent(f"""
|
|
451
|
+
# Move Failed - File Extension Required
|
|
452
|
+
|
|
453
|
+
The destination path '{destination_path}' must include a file extension (e.g., '.md').
|
|
454
|
+
|
|
455
|
+
## Valid examples:
|
|
456
|
+
- `notes/my-note.md`
|
|
457
|
+
- `projects/meeting-2025.txt`
|
|
458
|
+
- `archive/old-program.sh`
|
|
459
|
+
|
|
460
|
+
## Try again with extension:
|
|
461
|
+
```
|
|
462
|
+
move_note("{identifier}", "{destination_path}.{source_ext}")
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
All examples in Basic Memory expect file extensions to be explicitly provided.
|
|
466
|
+
""").strip()
|
|
467
|
+
|
|
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())
|
|
474
|
+
|
|
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 ""
|
|
480
|
+
|
|
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)
|