basic-memory 0.12.3__py3-none-any.whl → 0.13.0b1__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 +7 -1
- basic_memory/alembic/env.py +1 -1
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -5
- basic_memory/api/app.py +43 -13
- basic_memory/api/routers/__init__.py +4 -2
- basic_memory/api/routers/directory_router.py +63 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +127 -38
- basic_memory/api/routers/management_router.py +78 -0
- basic_memory/api/routers/memory_router.py +4 -59
- basic_memory/api/routers/project_router.py +230 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/search_router.py +3 -21
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +20 -21
- basic_memory/cli/commands/__init__.py +2 -1
- basic_memory/cli/commands/auth.py +136 -0
- basic_memory/cli/commands/db.py +3 -3
- basic_memory/cli/commands/import_chatgpt.py +31 -207
- basic_memory/cli/commands/import_claude_conversations.py +16 -142
- basic_memory/cli/commands/import_claude_projects.py +33 -143
- basic_memory/cli/commands/import_memory_json.py +26 -83
- basic_memory/cli/commands/mcp.py +71 -18
- basic_memory/cli/commands/project.py +99 -67
- basic_memory/cli/commands/status.py +19 -9
- basic_memory/cli/commands/sync.py +44 -58
- basic_memory/cli/main.py +1 -5
- basic_memory/config.py +145 -88
- basic_memory/db.py +6 -4
- basic_memory/deps.py +227 -30
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +222 -0
- basic_memory/importers/claude_conversations_importer.py +172 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +93 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +5 -2
- basic_memory/mcp/auth_provider.py +270 -0
- basic_memory/mcp/external_auth_provider.py +321 -0
- basic_memory/mcp/project_session.py +103 -0
- basic_memory/mcp/prompts/continue_conversation.py +18 -68
- basic_memory/mcp/prompts/recent_activity.py +19 -3
- basic_memory/mcp/prompts/search.py +14 -140
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/{tools → resources}/project_info.py +6 -2
- basic_memory/mcp/server.py +82 -8
- basic_memory/mcp/supabase_auth_provider.py +463 -0
- basic_memory/mcp/tools/__init__.py +20 -0
- basic_memory/mcp/tools/build_context.py +11 -1
- basic_memory/mcp/tools/canvas.py +15 -2
- basic_memory/mcp/tools/delete_note.py +12 -4
- basic_memory/mcp/tools/edit_note.py +297 -0
- basic_memory/mcp/tools/list_directory.py +154 -0
- basic_memory/mcp/tools/move_note.py +87 -0
- basic_memory/mcp/tools/project_management.py +300 -0
- basic_memory/mcp/tools/read_content.py +15 -6
- basic_memory/mcp/tools/read_note.py +17 -5
- basic_memory/mcp/tools/recent_activity.py +11 -2
- basic_memory/mcp/tools/search.py +10 -1
- basic_memory/mcp/tools/utils.py +137 -12
- basic_memory/mcp/tools/write_note.py +11 -15
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +16 -4
- basic_memory/models/project.py +80 -0
- basic_memory/models/search.py +8 -5
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +8 -3
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +3 -2
- basic_memory/repository/project_repository.py +85 -0
- basic_memory/repository/relation_repository.py +8 -2
- basic_memory/repository/repository.py +107 -15
- basic_memory/repository/search_repository.py +87 -27
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +34 -0
- basic_memory/schemas/memory.py +26 -12
- basic_memory/schemas/project_info.py +112 -2
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/search.py +1 -1
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +208 -95
- basic_memory/services/directory_service.py +167 -0
- basic_memory/services/entity_service.py +385 -5
- basic_memory/services/exceptions.py +6 -0
- basic_memory/services/file_service.py +14 -15
- basic_memory/services/initialization.py +144 -67
- basic_memory/services/link_resolver.py +16 -8
- basic_memory/services/project_service.py +548 -0
- basic_memory/services/search_service.py +77 -2
- basic_memory/sync/background_sync.py +25 -0
- basic_memory/sync/sync_service.py +10 -9
- basic_memory/sync/watch_service.py +63 -39
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/METADATA +23 -1
- basic_memory-0.13.0b1.dist-info/RECORD +132 -0
- basic_memory/api/routers/project_info_router.py +0 -274
- basic_memory/mcp/main.py +0 -24
- basic_memory-0.12.3.dist-info/RECORD +0 -100
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/WHEEL +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Edit note tool for Basic Memory MCP server."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from basic_memory.mcp.async_client import client
|
|
8
|
+
from basic_memory.mcp.project_session import get_active_project
|
|
9
|
+
from basic_memory.mcp.server import mcp
|
|
10
|
+
from basic_memory.mcp.tools.utils import call_patch
|
|
11
|
+
from basic_memory.schemas import EntityResponse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _format_error_response(
|
|
15
|
+
error_message: str,
|
|
16
|
+
operation: str,
|
|
17
|
+
identifier: str,
|
|
18
|
+
find_text: Optional[str] = None,
|
|
19
|
+
expected_replacements: int = 1,
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Format helpful error responses for edit_note failures that guide the AI to retry successfully."""
|
|
22
|
+
|
|
23
|
+
# Entity not found errors
|
|
24
|
+
if "Entity not found" in error_message or "entity not found" in error_message.lower():
|
|
25
|
+
return f"""# Edit Failed - Note Not Found
|
|
26
|
+
|
|
27
|
+
The note with identifier '{identifier}' could not be found.
|
|
28
|
+
|
|
29
|
+
## Suggestions to try:
|
|
30
|
+
1. **Search for the note first**: Use `search_notes("{identifier.split("/")[-1]}")` to find similar notes
|
|
31
|
+
2. **Try different identifier formats**:
|
|
32
|
+
- If you used a permalink like "folder/note-title", try just the title: "{identifier.split("/")[-1].replace("-", " ").title()}"
|
|
33
|
+
- If you used a title, try the permalink format: "{identifier.lower().replace(" ", "-")}"
|
|
34
|
+
- Use `read_note()` first to verify the note exists and get the correct identifiers
|
|
35
|
+
|
|
36
|
+
## Alternative approach:
|
|
37
|
+
Use `write_note()` to create the note first, then edit it."""
|
|
38
|
+
|
|
39
|
+
# Find/replace specific errors
|
|
40
|
+
if operation == "find_replace":
|
|
41
|
+
if "Text to replace not found" in error_message:
|
|
42
|
+
return f"""# Edit Failed - Text Not Found
|
|
43
|
+
|
|
44
|
+
The text '{find_text}' was not found in the note '{identifier}'.
|
|
45
|
+
|
|
46
|
+
## Suggestions to try:
|
|
47
|
+
1. **Read the note first**: Use `read_note("{identifier}")` to see the current content
|
|
48
|
+
2. **Check for exact matches**: The search is case-sensitive and must match exactly
|
|
49
|
+
3. **Try a broader search**: Search for just part of the text you want to replace
|
|
50
|
+
4. **Use expected_replacements=0**: If you want to verify the text doesn't exist
|
|
51
|
+
|
|
52
|
+
## Alternative approaches:
|
|
53
|
+
- Use `append` or `prepend` to add new content instead
|
|
54
|
+
- Use `replace_section` if you're trying to update a specific section"""
|
|
55
|
+
|
|
56
|
+
if "Expected" in error_message and "occurrences" in error_message:
|
|
57
|
+
# Extract the actual count from error message if possible
|
|
58
|
+
import re
|
|
59
|
+
|
|
60
|
+
match = re.search(r"found (\d+)", error_message)
|
|
61
|
+
actual_count = match.group(1) if match else "a different number of"
|
|
62
|
+
|
|
63
|
+
return f"""# Edit Failed - Wrong Replacement Count
|
|
64
|
+
|
|
65
|
+
Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}.
|
|
66
|
+
|
|
67
|
+
## How to fix:
|
|
68
|
+
1. **Read the note first**: Use `read_note("{identifier}")` to see how many times '{find_text}' appears
|
|
69
|
+
2. **Update expected_replacements**: Set expected_replacements={actual_count} in your edit_note call
|
|
70
|
+
3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific
|
|
71
|
+
|
|
72
|
+
## Example:
|
|
73
|
+
```
|
|
74
|
+
edit_note("{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count})
|
|
75
|
+
```"""
|
|
76
|
+
|
|
77
|
+
# Section replacement errors
|
|
78
|
+
if operation == "replace_section" and "Multiple sections" in error_message:
|
|
79
|
+
return f"""# Edit Failed - Duplicate Section Headers
|
|
80
|
+
|
|
81
|
+
Multiple sections found with the same header in note '{identifier}'.
|
|
82
|
+
|
|
83
|
+
## How to fix:
|
|
84
|
+
1. **Read the note first**: Use `read_note("{identifier}")` to see the document structure
|
|
85
|
+
2. **Make headers unique**: Add more specific text to distinguish sections
|
|
86
|
+
3. **Use append instead**: Add content at the end rather than replacing a specific section
|
|
87
|
+
|
|
88
|
+
## Alternative approach:
|
|
89
|
+
Use `find_replace` to update specific text within the duplicate sections."""
|
|
90
|
+
|
|
91
|
+
# Generic server/request errors
|
|
92
|
+
if (
|
|
93
|
+
"Invalid request" in error_message or "malformed" in error_message.lower()
|
|
94
|
+
): # pragma: no cover
|
|
95
|
+
return f"""# Edit Failed - Request Error
|
|
96
|
+
|
|
97
|
+
There was a problem with the edit request to note '{identifier}': {error_message}.
|
|
98
|
+
|
|
99
|
+
## Common causes and fixes:
|
|
100
|
+
1. **Note doesn't exist**: Use `search_notes()` or `read_note()` to verify the note exists
|
|
101
|
+
2. **Invalid identifier format**: Try different identifier formats (title vs permalink)
|
|
102
|
+
3. **Empty or invalid content**: Check that your content is properly formatted
|
|
103
|
+
4. **Server error**: Try the operation again, or use `read_note()` first to verify the note state
|
|
104
|
+
|
|
105
|
+
## 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
|
+
3. Try again with the correct identifier from the search results"""
|
|
109
|
+
|
|
110
|
+
# Fallback for other errors
|
|
111
|
+
return f"""# Edit Failed
|
|
112
|
+
|
|
113
|
+
Error editing note '{identifier}': {error_message}
|
|
114
|
+
|
|
115
|
+
## General troubleshooting:
|
|
116
|
+
1. **Verify the note exists**: Use `read_note("{identifier}")` to check
|
|
117
|
+
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
|
|
119
|
+
4. **Try a simpler operation**: Start with `append` if other operations fail
|
|
120
|
+
|
|
121
|
+
## Need help?
|
|
122
|
+
- Use `search_notes()` to find notes
|
|
123
|
+
- Use `read_note()` to examine content before editing
|
|
124
|
+
- Check that identifiers, section headers, and find_text match exactly"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@mcp.tool(
|
|
128
|
+
description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
|
|
129
|
+
)
|
|
130
|
+
async def edit_note(
|
|
131
|
+
identifier: str,
|
|
132
|
+
operation: str,
|
|
133
|
+
content: str,
|
|
134
|
+
section: Optional[str] = None,
|
|
135
|
+
find_text: Optional[str] = None,
|
|
136
|
+
expected_replacements: int = 1,
|
|
137
|
+
project: Optional[str] = None,
|
|
138
|
+
) -> str:
|
|
139
|
+
"""Edit an existing markdown note in the knowledge base.
|
|
140
|
+
|
|
141
|
+
This tool allows you to make targeted changes to existing notes without rewriting the entire content.
|
|
142
|
+
It supports various operations for different editing scenarios.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
identifier: The title, permalink, or memory:// URL of the note to edit
|
|
146
|
+
operation: The editing operation to perform:
|
|
147
|
+
- "append": Add content to the end of the note
|
|
148
|
+
- "prepend": Add content to the beginning of the note
|
|
149
|
+
- "find_replace": Replace occurrences of find_text with content
|
|
150
|
+
- "replace_section": Replace content under a specific markdown header
|
|
151
|
+
content: The content to add or use for replacement
|
|
152
|
+
section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
|
|
153
|
+
find_text: For find_replace operation - the text to find and replace
|
|
154
|
+
expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
|
|
155
|
+
project: Optional project name to delete from. If not provided, uses current active project.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
A markdown formatted summary of the edit operation and resulting semantic content
|
|
159
|
+
|
|
160
|
+
Examples:
|
|
161
|
+
# Add new content to end of note
|
|
162
|
+
edit_note("project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y")
|
|
163
|
+
|
|
164
|
+
# Add timestamp at beginning (frontmatter-aware)
|
|
165
|
+
edit_note("meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n")
|
|
166
|
+
|
|
167
|
+
# Update version number (single occurrence)
|
|
168
|
+
edit_note("config-spec", "find_replace", "v0.13.0", find_text="v0.12.0")
|
|
169
|
+
|
|
170
|
+
# Update version in multiple places with validation
|
|
171
|
+
edit_note("api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3)
|
|
172
|
+
|
|
173
|
+
# Replace text that appears multiple times - validate count first
|
|
174
|
+
edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5)
|
|
175
|
+
|
|
176
|
+
# Replace implementation section
|
|
177
|
+
edit_note("api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation")
|
|
178
|
+
|
|
179
|
+
# Replace subsection with more specific header
|
|
180
|
+
edit_note("docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
|
|
181
|
+
|
|
182
|
+
# Using different identifier formats
|
|
183
|
+
edit_note("Meeting Notes", "append", "\\n- Follow up on action items") # title
|
|
184
|
+
edit_note("docs/meeting-notes", "append", "\\n- Follow up tasks") # permalink
|
|
185
|
+
edit_note("docs/Meeting Notes", "append", "\\n- Next steps") # folder/title
|
|
186
|
+
|
|
187
|
+
# Add new section to document
|
|
188
|
+
edit_note("project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
|
|
189
|
+
|
|
190
|
+
# Update status across document (expecting exactly 2 occurrences)
|
|
191
|
+
edit_note("status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)
|
|
192
|
+
|
|
193
|
+
# Replace text in a file, specifying project name
|
|
194
|
+
edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", project="my-project"))
|
|
195
|
+
|
|
196
|
+
"""
|
|
197
|
+
active_project = get_active_project(project)
|
|
198
|
+
project_url = active_project.project_url
|
|
199
|
+
|
|
200
|
+
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
|
|
201
|
+
|
|
202
|
+
# Validate operation
|
|
203
|
+
valid_operations = ["append", "prepend", "find_replace", "replace_section"]
|
|
204
|
+
if operation not in valid_operations:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Validate required parameters for specific operations
|
|
210
|
+
if operation == "find_replace" and not find_text:
|
|
211
|
+
raise ValueError("find_text parameter is required for find_replace operation")
|
|
212
|
+
if operation == "replace_section" and not section:
|
|
213
|
+
raise ValueError("section parameter is required for replace_section operation")
|
|
214
|
+
|
|
215
|
+
# Use the PATCH endpoint to edit the entity
|
|
216
|
+
try:
|
|
217
|
+
# Prepare the edit request data
|
|
218
|
+
edit_data = {
|
|
219
|
+
"operation": operation,
|
|
220
|
+
"content": content,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# Add optional parameters
|
|
224
|
+
if section:
|
|
225
|
+
edit_data["section"] = section
|
|
226
|
+
if find_text:
|
|
227
|
+
edit_data["find_text"] = find_text
|
|
228
|
+
if expected_replacements != 1: # Only send if different from default
|
|
229
|
+
edit_data["expected_replacements"] = str(expected_replacements)
|
|
230
|
+
|
|
231
|
+
# Call the PATCH endpoint
|
|
232
|
+
url = f"{project_url}/knowledge/entities/{identifier}"
|
|
233
|
+
response = await call_patch(client, url, json=edit_data)
|
|
234
|
+
result = EntityResponse.model_validate(response.json())
|
|
235
|
+
|
|
236
|
+
# Format summary
|
|
237
|
+
summary = [
|
|
238
|
+
f"# Edited note ({operation})",
|
|
239
|
+
f"project: {active_project.name}",
|
|
240
|
+
f"file_path: {result.file_path}",
|
|
241
|
+
f"permalink: {result.permalink}",
|
|
242
|
+
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
# Add operation-specific details
|
|
246
|
+
if operation == "append":
|
|
247
|
+
lines_added = len(content.split("\n"))
|
|
248
|
+
summary.append(f"operation: Added {lines_added} lines to end of note")
|
|
249
|
+
elif operation == "prepend":
|
|
250
|
+
lines_added = len(content.split("\n"))
|
|
251
|
+
summary.append(f"operation: Added {lines_added} lines to beginning of note")
|
|
252
|
+
elif operation == "find_replace":
|
|
253
|
+
# For find_replace, we can't easily count replacements from here
|
|
254
|
+
# since we don't have the original content, but the server handled it
|
|
255
|
+
summary.append("operation: Find and replace operation completed")
|
|
256
|
+
elif operation == "replace_section":
|
|
257
|
+
summary.append(f"operation: Replaced content under section '{section}'")
|
|
258
|
+
|
|
259
|
+
# Count observations by category (reuse logic from write_note)
|
|
260
|
+
categories = {}
|
|
261
|
+
if result.observations:
|
|
262
|
+
for obs in result.observations:
|
|
263
|
+
categories[obs.category] = categories.get(obs.category, 0) + 1
|
|
264
|
+
|
|
265
|
+
summary.append("\\n## Observations")
|
|
266
|
+
for category, count in sorted(categories.items()):
|
|
267
|
+
summary.append(f"- {category}: {count}")
|
|
268
|
+
|
|
269
|
+
# Count resolved/unresolved relations
|
|
270
|
+
unresolved = 0
|
|
271
|
+
resolved = 0
|
|
272
|
+
if result.relations:
|
|
273
|
+
unresolved = sum(1 for r in result.relations if not r.to_id)
|
|
274
|
+
resolved = len(result.relations) - unresolved
|
|
275
|
+
|
|
276
|
+
summary.append("\\n## Relations")
|
|
277
|
+
summary.append(f"- Resolved: {resolved}")
|
|
278
|
+
if unresolved:
|
|
279
|
+
summary.append(f"- Unresolved: {unresolved}")
|
|
280
|
+
|
|
281
|
+
logger.info(
|
|
282
|
+
"MCP tool response",
|
|
283
|
+
tool="edit_note",
|
|
284
|
+
operation=operation,
|
|
285
|
+
permalink=result.permalink,
|
|
286
|
+
observations_count=len(result.observations),
|
|
287
|
+
relations_count=len(result.relations),
|
|
288
|
+
status_code=response.status_code,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return "\n".join(summary)
|
|
292
|
+
|
|
293
|
+
except Exception as e:
|
|
294
|
+
logger.error(f"Error editing note: {e}")
|
|
295
|
+
return _format_error_response(
|
|
296
|
+
str(e), operation, identifier, find_text, expected_replacements
|
|
297
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""List directory tool for Basic Memory MCP server."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from basic_memory.mcp.async_client import client
|
|
8
|
+
from basic_memory.mcp.project_session import get_active_project
|
|
9
|
+
from basic_memory.mcp.server import mcp
|
|
10
|
+
from basic_memory.mcp.tools.utils import call_get
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@mcp.tool(
|
|
14
|
+
description="List directory contents with filtering and depth control.",
|
|
15
|
+
)
|
|
16
|
+
async def list_directory(
|
|
17
|
+
dir_name: str = "/",
|
|
18
|
+
depth: int = 1,
|
|
19
|
+
file_name_glob: Optional[str] = None,
|
|
20
|
+
project: Optional[str] = None,
|
|
21
|
+
) -> str:
|
|
22
|
+
"""List directory contents from the knowledge base with optional filtering.
|
|
23
|
+
|
|
24
|
+
This tool provides 'ls' functionality for browsing the knowledge base directory structure.
|
|
25
|
+
It can list immediate children or recursively explore subdirectories with depth control,
|
|
26
|
+
and supports glob pattern filtering for finding specific files.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
dir_name: Directory path to list (default: root "/")
|
|
30
|
+
Examples: "/", "/projects", "/research/ml"
|
|
31
|
+
depth: Recursion depth (1-10, default: 1 for immediate children only)
|
|
32
|
+
Higher values show subdirectory contents recursively
|
|
33
|
+
file_name_glob: Optional glob pattern for filtering file names
|
|
34
|
+
Examples: "*.md", "*meeting*", "project_*"
|
|
35
|
+
project: Optional project name to delete from. If not provided, uses current active project.
|
|
36
|
+
Returns:
|
|
37
|
+
Formatted listing of directory contents with file metadata
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
# List root directory contents
|
|
41
|
+
list_directory()
|
|
42
|
+
|
|
43
|
+
# List specific folder
|
|
44
|
+
list_directory(dir_name="/projects")
|
|
45
|
+
|
|
46
|
+
# Find all Python files
|
|
47
|
+
list_directory(file_name_glob="*.py")
|
|
48
|
+
|
|
49
|
+
# Deep exploration of research folder
|
|
50
|
+
list_directory(dir_name="/research", depth=3)
|
|
51
|
+
|
|
52
|
+
# Find meeting notes in projects folder
|
|
53
|
+
list_directory(dir_name="/projects", file_name_glob="*meeting*")
|
|
54
|
+
|
|
55
|
+
# Find meeting notes in a specific project
|
|
56
|
+
list_directory(dir_name="/projects", file_name_glob="*meeting*", project="work-project")
|
|
57
|
+
"""
|
|
58
|
+
active_project = get_active_project(project)
|
|
59
|
+
project_url = active_project.project_url
|
|
60
|
+
|
|
61
|
+
# Prepare query parameters
|
|
62
|
+
params = {
|
|
63
|
+
"dir_name": dir_name,
|
|
64
|
+
"depth": str(depth),
|
|
65
|
+
}
|
|
66
|
+
if file_name_glob:
|
|
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 = ""
|
|
82
|
+
if file_name_glob:
|
|
83
|
+
filter_desc = f" matching '{file_name_glob}'"
|
|
84
|
+
return f"No files found in directory '{dir_name}'{filter_desc}"
|
|
85
|
+
|
|
86
|
+
# Format the results
|
|
87
|
+
output_lines = []
|
|
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("")
|
|
110
|
+
|
|
111
|
+
# Display files with metadata
|
|
112
|
+
for node in files:
|
|
113
|
+
path_display = node["directory_path"]
|
|
114
|
+
title = node.get("title", "")
|
|
115
|
+
updated = node.get("updated_at", "")
|
|
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'}"
|
|
148
|
+
)
|
|
149
|
+
if files:
|
|
150
|
+
summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
|
|
151
|
+
|
|
152
|
+
output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
|
|
153
|
+
|
|
154
|
+
return "\n".join(output_lines)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Move note tool for Basic Memory MCP server."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from basic_memory.mcp.async_client import client
|
|
8
|
+
from basic_memory.mcp.server import mcp
|
|
9
|
+
from basic_memory.mcp.tools.utils import call_post
|
|
10
|
+
from basic_memory.mcp.project_session import get_active_project
|
|
11
|
+
from basic_memory.schemas import EntityResponse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@mcp.tool(
|
|
15
|
+
description="Move a note to a new location, updating database and maintaining links.",
|
|
16
|
+
)
|
|
17
|
+
async def move_note(
|
|
18
|
+
identifier: str,
|
|
19
|
+
destination_path: str,
|
|
20
|
+
project: Optional[str] = None,
|
|
21
|
+
) -> str:
|
|
22
|
+
"""Move a note to a new file location within the same project.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
identifier: Entity identifier (title, permalink, or memory:// URL)
|
|
26
|
+
destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
|
|
27
|
+
project: Optional project name (defaults to current session project)
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Success message with move details
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
- Move to new folder: move_note("My Note", "work/notes/my-note.md")
|
|
34
|
+
- Move by permalink: move_note("my-note-permalink", "archive/old-notes/my-note.md")
|
|
35
|
+
- Specify project: move_note("My Note", "archive/my-note.md", project="work-project")
|
|
36
|
+
|
|
37
|
+
Note: This operation moves notes within the specified project only. Moving notes
|
|
38
|
+
between different projects is not currently supported.
|
|
39
|
+
|
|
40
|
+
The move operation:
|
|
41
|
+
- Updates the entity's file_path in the database
|
|
42
|
+
- Moves the physical file on the filesystem
|
|
43
|
+
- Optionally updates permalinks if configured
|
|
44
|
+
- Re-indexes the entity for search
|
|
45
|
+
- Maintains all observations and relations
|
|
46
|
+
"""
|
|
47
|
+
logger.debug(f"Moving note: {identifier} to {destination_path}")
|
|
48
|
+
|
|
49
|
+
active_project = get_active_project(project)
|
|
50
|
+
project_url = active_project.project_url
|
|
51
|
+
|
|
52
|
+
# Prepare move request
|
|
53
|
+
move_data = {
|
|
54
|
+
"identifier": identifier,
|
|
55
|
+
"destination_path": destination_path,
|
|
56
|
+
"project": active_project.name,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Call the move API endpoint
|
|
60
|
+
url = f"{project_url}/knowledge/move"
|
|
61
|
+
response = await call_post(client, url, json=move_data)
|
|
62
|
+
result = EntityResponse.model_validate(response.json())
|
|
63
|
+
|
|
64
|
+
# 10. Build success message
|
|
65
|
+
result_lines = [
|
|
66
|
+
"✅ Note moved successfully",
|
|
67
|
+
"",
|
|
68
|
+
f"📁 **{identifier}** → **{result.file_path}**",
|
|
69
|
+
f"🔗 Permalink: {result.permalink}",
|
|
70
|
+
"📊 Database and search index updated",
|
|
71
|
+
"",
|
|
72
|
+
f"<!-- Project: {active_project.name} -->",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
# Return the response text which contains the formatted success message
|
|
76
|
+
result = "\n".join(result_lines)
|
|
77
|
+
|
|
78
|
+
# Log the operation
|
|
79
|
+
logger.info(
|
|
80
|
+
"Move note completed",
|
|
81
|
+
identifier=identifier,
|
|
82
|
+
destination_path=destination_path,
|
|
83
|
+
project=active_project.name,
|
|
84
|
+
status_code=response.status_code,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return result
|