basic-memory 0.14.4__py3-none-any.whl → 0.15.0__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/knowledge_router.py +25 -8
- basic_memory/api/routers/project_router.py +99 -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 +60 -0
- basic_memory/cli/commands/import_memory_json.py +0 -4
- basic_memory/cli/commands/mcp.py +16 -4
- basic_memory/cli/commands/project.py +139 -142
- basic_memory/cli/commands/status.py +34 -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 +76 -12
- basic_memory/db.py +104 -3
- basic_memory/deps.py +20 -3
- 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 +22 -10
- 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 +1 -1
- basic_memory/mcp/prompts/recent_activity.py +116 -32
- basic_memory/mcp/prompts/search.py +1 -1
- basic_memory/mcp/prompts/utils.py +11 -4
- basic_memory/mcp/resources/ai_assistant_guide.md +179 -41
- basic_memory/mcp/resources/project_info.py +20 -6
- basic_memory/mcp/server.py +0 -37
- basic_memory/mcp/tools/__init__.py +5 -6
- basic_memory/mcp/tools/build_context.py +29 -19
- basic_memory/mcp/tools/canvas.py +19 -8
- basic_memory/mcp/tools/chatgpt_tools.py +178 -0
- basic_memory/mcp/tools/delete_note.py +67 -34
- basic_memory/mcp/tools/edit_note.py +55 -39
- basic_memory/mcp/tools/headers.py +44 -0
- basic_memory/mcp/tools/list_directory.py +18 -8
- basic_memory/mcp/tools/move_note.py +119 -41
- basic_memory/mcp/tools/project_management.py +61 -228
- basic_memory/mcp/tools/read_content.py +28 -12
- basic_memory/mcp/tools/read_note.py +83 -46
- basic_memory/mcp/tools/recent_activity.py +441 -42
- basic_memory/mcp/tools/search.py +82 -70
- basic_memory/mcp/tools/sync_status.py +5 -4
- basic_memory/mcp/tools/utils.py +19 -0
- basic_memory/mcp/tools/view_note.py +31 -6
- basic_memory/mcp/tools/write_note.py +65 -14
- basic_memory/models/knowledge.py +12 -6
- basic_memory/models/project.py +6 -2
- basic_memory/repository/entity_repository.py +29 -82
- basic_memory/repository/relation_repository.py +13 -0
- basic_memory/repository/repository.py +2 -2
- basic_memory/repository/search_repository.py +4 -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/entity_service.py +75 -45
- basic_memory/services/initialization.py +30 -11
- basic_memory/services/project_service.py +13 -23
- basic_memory/sync/sync_service.py +145 -21
- 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.0.dist-info}/METADATA +7 -6
- basic_memory-0.15.0.dist-info/RECORD +147 -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.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.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
8
|
from basic_memory.mcp.async_client import client
|
|
8
|
-
from basic_memory.mcp.
|
|
9
|
+
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
|
9
10
|
from basic_memory.mcp.server import mcp
|
|
10
11
|
from basic_memory.mcp.tools.utils import call_patch
|
|
11
12
|
from basic_memory.schemas import EntityResponse
|
|
@@ -17,6 +18,7 @@ def _format_error_response(
|
|
|
17
18
|
identifier: str,
|
|
18
19
|
find_text: Optional[str] = None,
|
|
19
20
|
expected_replacements: int = 1,
|
|
21
|
+
project: Optional[str] = None,
|
|
20
22
|
) -> str:
|
|
21
23
|
"""Format helpful error responses for edit_note failures that guide the AI to retry successfully."""
|
|
22
24
|
|
|
@@ -27,14 +29,14 @@ def _format_error_response(
|
|
|
27
29
|
The note with identifier '{identifier}' could not be found. Edit operations require an exact match (no fuzzy matching).
|
|
28
30
|
|
|
29
31
|
## Suggestions to try:
|
|
30
|
-
1. **Search for the note first**: Use `search_notes("{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
|
|
32
|
+
1. **Search for the note first**: Use `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
|
|
31
33
|
2. **Try different exact identifier formats**:
|
|
32
34
|
- If you used a permalink like "folder/note-title", try the exact title: "{identifier.split("/")[-1].replace("-", " ").title()}"
|
|
33
35
|
- 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
|
|
36
|
+
- Use `read_note("{project or "project-name"}", "{identifier}")` first to verify the note exists and get the exact identifier
|
|
35
37
|
|
|
36
38
|
## Alternative approach:
|
|
37
|
-
Use `write_note()` to create the note first, then edit it."""
|
|
39
|
+
Use `write_note("{project or "project-name"}", "title", "content", "folder")` to create the note first, then edit it."""
|
|
38
40
|
|
|
39
41
|
# Find/replace specific errors
|
|
40
42
|
if operation == "find_replace":
|
|
@@ -44,7 +46,7 @@ Use `write_note()` to create the note first, then edit it."""
|
|
|
44
46
|
The text '{find_text}' was not found in the note '{identifier}'.
|
|
45
47
|
|
|
46
48
|
## Suggestions to try:
|
|
47
|
-
1. **Read the note first**: Use `read_note("{identifier}")` to see the current content
|
|
49
|
+
1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see the current content
|
|
48
50
|
2. **Check for exact matches**: The search is case-sensitive and must match exactly
|
|
49
51
|
3. **Try a broader search**: Search for just part of the text you want to replace
|
|
50
52
|
4. **Use expected_replacements=0**: If you want to verify the text doesn't exist
|
|
@@ -65,13 +67,13 @@ The text '{find_text}' was not found in the note '{identifier}'.
|
|
|
65
67
|
Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}.
|
|
66
68
|
|
|
67
69
|
## How to fix:
|
|
68
|
-
1. **Read the note first**: Use `read_note("{identifier}")` to see how many times '{find_text}' appears
|
|
70
|
+
1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see how many times '{find_text}' appears
|
|
69
71
|
2. **Update expected_replacements**: Set expected_replacements={actual_count} in your edit_note call
|
|
70
72
|
3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific
|
|
71
73
|
|
|
72
74
|
## Example:
|
|
73
75
|
```
|
|
74
|
-
edit_note("{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count})
|
|
76
|
+
edit_note("{project or "project-name"}", "{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count})
|
|
75
77
|
```"""
|
|
76
78
|
|
|
77
79
|
# Section replacement errors
|
|
@@ -81,7 +83,7 @@ edit_note("{identifier}", "find_replace", "new_text", find_text="{find_text}", e
|
|
|
81
83
|
Multiple sections found with the same header in note '{identifier}'.
|
|
82
84
|
|
|
83
85
|
## How to fix:
|
|
84
|
-
1. **Read the note first**: Use `read_note("{identifier}")` to see the document structure
|
|
86
|
+
1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see the document structure
|
|
85
87
|
2. **Make headers unique**: Add more specific text to distinguish sections
|
|
86
88
|
3. **Use append instead**: Add content at the end rather than replacing a specific section
|
|
87
89
|
|
|
@@ -97,14 +99,14 @@ Use `find_replace` to update specific text within the duplicate sections."""
|
|
|
97
99
|
There was a problem with the edit request to note '{identifier}': {error_message}.
|
|
98
100
|
|
|
99
101
|
## Common causes and fixes:
|
|
100
|
-
1. **Note doesn't exist**: Use `search_notes()` or `read_note()` to verify the note exists
|
|
102
|
+
1. **Note doesn't exist**: Use `search_notes("{project or "project-name"}", "query")` or `read_note("{project or "project-name"}", "{identifier}")` to verify the note exists
|
|
101
103
|
2. **Invalid identifier format**: Try different identifier formats (title vs permalink)
|
|
102
104
|
3. **Empty or invalid content**: Check that your content is properly formatted
|
|
103
105
|
4. **Server error**: Try the operation again, or use `read_note()` first to verify the note state
|
|
104
106
|
|
|
105
107
|
## Troubleshooting steps:
|
|
106
|
-
1. Verify the note exists: `read_note("{identifier}")`
|
|
107
|
-
2. If not found, search for it: `search_notes("{identifier.split("/")[-1]}")`
|
|
108
|
+
1. Verify the note exists: `read_note("{project or "project-name"}", "{identifier}")`
|
|
109
|
+
2. If not found, search for it: `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")`
|
|
108
110
|
3. Try again with the correct identifier from the search results"""
|
|
109
111
|
|
|
110
112
|
# Fallback for other errors
|
|
@@ -113,14 +115,14 @@ There was a problem with the edit request to note '{identifier}': {error_message
|
|
|
113
115
|
Error editing note '{identifier}': {error_message}
|
|
114
116
|
|
|
115
117
|
## General troubleshooting:
|
|
116
|
-
1. **Verify the note exists**: Use `read_note("{identifier}")` to check
|
|
118
|
+
1. **Verify the note exists**: Use `read_note("{project or "project-name"}", "{identifier}")` to check
|
|
117
119
|
2. **Check your parameters**: Ensure all required parameters are provided correctly
|
|
118
|
-
3. **Read the note content first**: Use `read_note()` to understand the current structure
|
|
120
|
+
3. **Read the note content first**: Use `read_note("{project or "project-name"}", "{identifier}")` to understand the current structure
|
|
119
121
|
4. **Try a simpler operation**: Start with `append` if other operations fail
|
|
120
122
|
|
|
121
123
|
## Need help?
|
|
122
|
-
- Use `search_notes()` to find notes
|
|
123
|
-
- Use `read_note()` to examine content before editing
|
|
124
|
+
- Use `search_notes("{project or "project-name"}", "query")` to find notes
|
|
125
|
+
- Use `read_note("{project or "project-name"}", "identifier")` to examine content before editing
|
|
124
126
|
- Check that identifiers, section headers, and find_text match exactly"""
|
|
125
127
|
|
|
126
128
|
|
|
@@ -131,15 +133,19 @@ async def edit_note(
|
|
|
131
133
|
identifier: str,
|
|
132
134
|
operation: str,
|
|
133
135
|
content: str,
|
|
136
|
+
project: Optional[str] = None,
|
|
134
137
|
section: Optional[str] = None,
|
|
135
138
|
find_text: Optional[str] = None,
|
|
136
139
|
expected_replacements: int = 1,
|
|
137
|
-
|
|
140
|
+
context: Context | None = None,
|
|
138
141
|
) -> str:
|
|
139
142
|
"""Edit an existing markdown note in the knowledge base.
|
|
140
143
|
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
Makes targeted changes to existing notes without rewriting the entire content.
|
|
145
|
+
|
|
146
|
+
Project Resolution:
|
|
147
|
+
Server resolves projects in this order: Single Project Mode → project parameter → default project.
|
|
148
|
+
If project unknown, use list_memory_projects() or recent_activity() first.
|
|
143
149
|
|
|
144
150
|
Args:
|
|
145
151
|
identifier: The exact title, permalink, or memory:// URL of the note to edit.
|
|
@@ -151,56 +157,64 @@ async def edit_note(
|
|
|
151
157
|
- "find_replace": Replace occurrences of find_text with content
|
|
152
158
|
- "replace_section": Replace content under a specific markdown header
|
|
153
159
|
content: The content to add or use for replacement
|
|
160
|
+
project: Project name to edit in. Optional - server will resolve using hierarchy.
|
|
161
|
+
If unknown, use list_memory_projects() to discover available projects.
|
|
154
162
|
section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
|
|
155
163
|
find_text: For find_replace operation - the text to find and replace
|
|
156
164
|
expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
|
|
157
|
-
|
|
165
|
+
context: Optional FastMCP context for performance caching.
|
|
158
166
|
|
|
159
167
|
Returns:
|
|
160
|
-
A markdown formatted summary of the edit operation and resulting semantic content
|
|
168
|
+
A markdown formatted summary of the edit operation and resulting semantic content,
|
|
169
|
+
including operation details, file path, observations, relations, and project metadata.
|
|
161
170
|
|
|
162
171
|
Examples:
|
|
163
172
|
# Add new content to end of note
|
|
164
|
-
edit_note("project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y")
|
|
173
|
+
edit_note("my-project", "project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y")
|
|
165
174
|
|
|
166
175
|
# Add timestamp at beginning (frontmatter-aware)
|
|
167
|
-
edit_note("meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n")
|
|
176
|
+
edit_note("work-docs", "meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n")
|
|
168
177
|
|
|
169
178
|
# Update version number (single occurrence)
|
|
170
|
-
edit_note("config-spec", "find_replace", "v0.13.0", find_text="v0.12.0")
|
|
179
|
+
edit_note("api-project", "config-spec", "find_replace", "v0.13.0", find_text="v0.12.0")
|
|
171
180
|
|
|
172
181
|
# Update version in multiple places with validation
|
|
173
|
-
edit_note("api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3)
|
|
182
|
+
edit_note("docs-project", "api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3)
|
|
174
183
|
|
|
175
184
|
# Replace text that appears multiple times - validate count first
|
|
176
|
-
edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5)
|
|
185
|
+
edit_note("team-docs", "docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5)
|
|
177
186
|
|
|
178
187
|
# Replace implementation section
|
|
179
|
-
edit_note("api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation")
|
|
188
|
+
edit_note("specs", "api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation")
|
|
180
189
|
|
|
181
190
|
# Replace subsection with more specific header
|
|
182
|
-
edit_note("docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
|
|
191
|
+
edit_note("docs", "docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
|
|
183
192
|
|
|
184
193
|
# 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
|
|
194
|
+
edit_note("work-project", "Meeting Notes", "append", "\\n- Follow up on action items") # exact title
|
|
195
|
+
edit_note("work-project", "docs/meeting-notes", "append", "\\n- Follow up tasks") # exact permalink
|
|
188
196
|
|
|
189
197
|
# 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
|
|
198
|
+
# search_notes("work-project", "meeting") # Find available notes
|
|
199
|
+
# edit_note("work-project", "docs/meeting-notes-2025", "append", "content") # Use exact result
|
|
192
200
|
|
|
193
201
|
# Add new section to document
|
|
194
|
-
edit_note("project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
|
|
202
|
+
edit_note("planning", "project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
|
|
195
203
|
|
|
196
204
|
# Update status across document (expecting exactly 2 occurrences)
|
|
197
|
-
edit_note("status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)
|
|
205
|
+
edit_note("reports", "status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)
|
|
198
206
|
|
|
199
|
-
|
|
200
|
-
|
|
207
|
+
Raises:
|
|
208
|
+
HTTPError: If project doesn't exist or is inaccessible
|
|
209
|
+
ValueError: If operation is invalid or required parameters are missing
|
|
210
|
+
SecurityError: If identifier attempts path traversal
|
|
201
211
|
|
|
212
|
+
Note:
|
|
213
|
+
Edit operations require exact identifier matches. If unsure, use read_note() or
|
|
214
|
+
search_notes() first to find the correct identifier. The tool provides detailed
|
|
215
|
+
error messages with suggestions if operations fail.
|
|
202
216
|
"""
|
|
203
|
-
active_project = get_active_project(project)
|
|
217
|
+
active_project = await get_active_project(client, project, context)
|
|
204
218
|
project_url = active_project.project_url
|
|
205
219
|
|
|
206
220
|
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
|
|
@@ -288,16 +302,18 @@ async def edit_note(
|
|
|
288
302
|
"MCP tool response",
|
|
289
303
|
tool="edit_note",
|
|
290
304
|
operation=operation,
|
|
305
|
+
project=active_project.name,
|
|
291
306
|
permalink=result.permalink,
|
|
292
307
|
observations_count=len(result.observations),
|
|
293
308
|
relations_count=len(result.relations),
|
|
294
309
|
status_code=response.status_code,
|
|
295
310
|
)
|
|
296
311
|
|
|
297
|
-
|
|
312
|
+
result = "\n".join(summary)
|
|
313
|
+
return add_project_metadata(result, active_project.name)
|
|
298
314
|
|
|
299
315
|
except Exception as e:
|
|
300
316
|
logger.error(f"Error editing note: {e}")
|
|
301
317
|
return _format_error_response(
|
|
302
|
-
str(e), operation, identifier, find_text, expected_replacements
|
|
318
|
+
str(e), operation, identifier, find_text, expected_replacements, active_project.name
|
|
303
319
|
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from httpx._types import (
|
|
2
|
+
HeaderTypes,
|
|
3
|
+
)
|
|
4
|
+
from loguru import logger
|
|
5
|
+
from fastmcp.server.dependencies import get_http_headers
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def inject_auth_header(headers: HeaderTypes | None = None) -> HeaderTypes:
|
|
9
|
+
"""
|
|
10
|
+
Inject JWT token from FastMCP context into headers if available.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
headers: Existing headers dict or None
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Headers dict with Authorization header added if JWT is available
|
|
17
|
+
"""
|
|
18
|
+
# Start with existing headers or empty dict
|
|
19
|
+
if headers is None:
|
|
20
|
+
headers = {}
|
|
21
|
+
elif not isinstance(headers, dict):
|
|
22
|
+
# Convert other header types to dict
|
|
23
|
+
headers = dict(headers) # type: ignore
|
|
24
|
+
else:
|
|
25
|
+
# Make a copy to avoid modifying the original
|
|
26
|
+
headers = headers.copy()
|
|
27
|
+
|
|
28
|
+
http_headers = get_http_headers()
|
|
29
|
+
|
|
30
|
+
# Log only non-sensitive header keys for debugging
|
|
31
|
+
if logger.opt(lazy=True).debug:
|
|
32
|
+
sensitive_headers = {"authorization", "cookie", "x-api-key", "x-auth-token", "api-key"}
|
|
33
|
+
safe_headers = {k for k in http_headers.keys() if k.lower() not in sensitive_headers}
|
|
34
|
+
logger.debug(f"HTTP headers present: {list(safe_headers)}")
|
|
35
|
+
|
|
36
|
+
authorization = http_headers.get("Authorization") or http_headers.get("authorization")
|
|
37
|
+
if authorization:
|
|
38
|
+
headers["Authorization"] = authorization # type: ignore
|
|
39
|
+
# Log only that auth was injected, not the token value
|
|
40
|
+
logger.debug("Injected authorization header into request")
|
|
41
|
+
else:
|
|
42
|
+
logger.debug("No authorization header found in request")
|
|
43
|
+
|
|
44
|
+
return headers
|
|
@@ -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
8
|
from basic_memory.mcp.async_client import client
|
|
8
|
-
from basic_memory.mcp.
|
|
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,10 +57,13 @@ 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
|
-
active_project = get_active_project(project)
|
|
66
|
+
active_project = await get_active_project(client, project, context)
|
|
59
67
|
project_url = active_project.project_url
|
|
60
68
|
|
|
61
69
|
# Prepare query parameters
|
|
@@ -66,7 +74,9 @@ async def list_directory(
|
|
|
66
74
|
if file_name_glob:
|
|
67
75
|
params["file_name_glob"] = file_name_glob
|
|
68
76
|
|
|
69
|
-
logger.debug(
|
|
77
|
+
logger.debug(
|
|
78
|
+
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
|
|
79
|
+
)
|
|
70
80
|
|
|
71
81
|
# Call the API endpoint
|
|
72
82
|
response = await call_get(
|
|
@@ -4,11 +4,12 @@ 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
9
|
from basic_memory.mcp.async_client import 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
|
|
@@ -64,42 +65,37 @@ def _format_cross_project_error_response(
|
|
|
64
65
|
"""Format error response for detected cross-project move attempts."""
|
|
65
66
|
return dedent(f"""
|
|
66
67
|
# Move Failed - Cross-Project Move Not Supported
|
|
67
|
-
|
|
68
|
+
|
|
68
69
|
Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}').
|
|
69
|
-
|
|
70
|
+
|
|
70
71
|
**Current project:** {current_project}
|
|
71
72
|
**Target project:** {target_project}
|
|
72
|
-
|
|
73
|
+
|
|
73
74
|
## Cross-project moves are not supported directly
|
|
74
|
-
|
|
75
|
+
|
|
75
76
|
Notes can only be moved within the same project. To move content between projects, use this workflow:
|
|
76
|
-
|
|
77
|
+
|
|
77
78
|
### Recommended approach:
|
|
78
79
|
```
|
|
79
80
|
# 1. Read the note content from current project
|
|
80
81
|
read_note("{identifier}")
|
|
81
82
|
|
|
82
|
-
# 2.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
# 3.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
# 4. Switch back to original project (optional)
|
|
89
|
-
switch_project("{current_project}")
|
|
83
|
+
# 2. Create the note in the target project
|
|
84
|
+
write_note("Note Title", "content from step 1", "target-folder", project="{target_project}")
|
|
85
|
+
|
|
86
|
+
# 3. Delete the original note if desired
|
|
87
|
+
delete_note("{identifier}", project="{current_project}")
|
|
90
88
|
|
|
91
|
-
# 5. Delete the original note if desired
|
|
92
|
-
delete_note("{identifier}")
|
|
93
89
|
```
|
|
94
|
-
|
|
90
|
+
|
|
95
91
|
### Alternative: Stay in current project
|
|
96
92
|
If you want to move the note within the **{current_project}** project only:
|
|
97
93
|
```
|
|
98
94
|
move_note("{identifier}", "new-folder/new-name.md")
|
|
99
95
|
```
|
|
100
|
-
|
|
96
|
+
|
|
101
97
|
## Available projects:
|
|
102
|
-
Use `
|
|
98
|
+
Use `list_memory_projects()` to see all available projects.
|
|
103
99
|
""").strip()
|
|
104
100
|
|
|
105
101
|
|
|
@@ -130,20 +126,16 @@ def _format_potential_cross_project_guidance(
|
|
|
130
126
|
# 1. Read the content
|
|
131
127
|
read_note("{identifier}")
|
|
132
128
|
|
|
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}")
|
|
129
|
+
# 2. Create note in target project
|
|
130
|
+
write_note("Title", "content", "folder", project="target-project-name")
|
|
131
|
+
|
|
132
|
+
# 3. Delete original if desired
|
|
133
|
+
delete_note("{identifier}", project="{current_project}")
|
|
142
134
|
```
|
|
143
135
|
|
|
144
136
|
### To see all projects:
|
|
145
137
|
```
|
|
146
|
-
|
|
138
|
+
list_memory_projects()
|
|
147
139
|
```
|
|
148
140
|
""").strip()
|
|
149
141
|
|
|
@@ -171,7 +163,7 @@ def _format_move_error_response(error_message: str, identifier: str, destination
|
|
|
171
163
|
- If you used a title, try the exact permalink format: "{permalink_format}"
|
|
172
164
|
- Use `read_note()` first to verify the note exists and get the exact identifier
|
|
173
165
|
|
|
174
|
-
3. **
|
|
166
|
+
3. **List available notes**: Use `list_directory("/")` to see what notes exist in the current project
|
|
175
167
|
4. **List available notes**: Use `list_directory("/")` to see what notes exist
|
|
176
168
|
|
|
177
169
|
## Before trying again:
|
|
@@ -248,9 +240,8 @@ You don't have permission to move '{identifier}': {error_message}
|
|
|
248
240
|
3. **Check file locks**: The file might be open in another application
|
|
249
241
|
|
|
250
242
|
## Alternative actions:
|
|
251
|
-
-
|
|
252
|
-
-
|
|
253
|
-
- Try copying content instead: `read_note("{identifier}")` then `write_note()` to new location"""
|
|
243
|
+
- List available projects: `list_memory_projects()`
|
|
244
|
+
- Try copying content instead: `read_note("{identifier}", project="project-name")` then `write_note()` to new location"""
|
|
254
245
|
|
|
255
246
|
# Source file not found errors
|
|
256
247
|
if "source" in error_message.lower() and (
|
|
@@ -352,18 +343,25 @@ async def move_note(
|
|
|
352
343
|
identifier: str,
|
|
353
344
|
destination_path: str,
|
|
354
345
|
project: Optional[str] = None,
|
|
346
|
+
context: Context | None = None,
|
|
355
347
|
) -> str:
|
|
356
348
|
"""Move a note to a new file location within the same project.
|
|
357
349
|
|
|
350
|
+
Moves a note from one location to another within the project, updating all
|
|
351
|
+
database references and maintaining semantic content. Uses stateless architecture -
|
|
352
|
+
project parameter optional with server resolution.
|
|
353
|
+
|
|
358
354
|
Args:
|
|
359
355
|
identifier: Exact entity identifier (title, permalink, or memory:// URL).
|
|
360
356
|
Must be an exact match - fuzzy matching is not supported for move operations.
|
|
361
357
|
Use search_notes() or read_note() first to find the correct identifier if uncertain.
|
|
362
358
|
destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
|
|
363
|
-
project: Optional
|
|
359
|
+
project: Project name to move within. Optional - server will resolve using hierarchy.
|
|
360
|
+
If unknown, use list_memory_projects() to discover available projects.
|
|
361
|
+
context: Optional FastMCP context for performance caching.
|
|
364
362
|
|
|
365
363
|
Returns:
|
|
366
|
-
Success message with move details
|
|
364
|
+
Success message with move details and project information.
|
|
367
365
|
|
|
368
366
|
Examples:
|
|
369
367
|
# Move to new folder (exact title match)
|
|
@@ -372,15 +370,22 @@ async def move_note(
|
|
|
372
370
|
# Move by exact permalink
|
|
373
371
|
move_note("my-note-permalink", "archive/old-notes/my-note.md")
|
|
374
372
|
|
|
375
|
-
#
|
|
376
|
-
move_note("
|
|
373
|
+
# Move with complex path structure
|
|
374
|
+
move_note("experiments/ml-results", "archive/2025/ml-experiments.md")
|
|
375
|
+
|
|
376
|
+
# Explicit project specification
|
|
377
|
+
move_note("My Note", "work/notes/my-note.md", project="work-project")
|
|
377
378
|
|
|
378
379
|
# If uncertain about identifier, search first:
|
|
379
380
|
# search_notes("my note") # Find available notes
|
|
380
381
|
# move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result
|
|
381
382
|
|
|
382
|
-
|
|
383
|
-
|
|
383
|
+
Raises:
|
|
384
|
+
ToolError: If project doesn't exist, identifier is not found, or destination_path is invalid
|
|
385
|
+
|
|
386
|
+
Note:
|
|
387
|
+
This operation moves notes within the specified project only. Moving notes
|
|
388
|
+
between different projects is not currently supported.
|
|
384
389
|
|
|
385
390
|
The move operation:
|
|
386
391
|
- Updates the entity's file_path in the database
|
|
@@ -389,9 +394,9 @@ async def move_note(
|
|
|
389
394
|
- Re-indexes the entity for search
|
|
390
395
|
- Maintains all observations and relations
|
|
391
396
|
"""
|
|
392
|
-
logger.debug(f"Moving note: {identifier} to {destination_path}")
|
|
397
|
+
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
|
|
393
398
|
|
|
394
|
-
active_project = get_active_project(project)
|
|
399
|
+
active_project = await get_active_project(client, project, context)
|
|
395
400
|
project_url = active_project.project_url
|
|
396
401
|
|
|
397
402
|
# Validate destination path to prevent path traversal attacks
|
|
@@ -424,6 +429,79 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
|
|
424
429
|
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
|
|
425
430
|
return cross_project_error
|
|
426
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 ""
|
|
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
|
+
|
|
427
505
|
try:
|
|
428
506
|
# Prepare move request
|
|
429
507
|
move_data = {
|