Skip to content

Commit e59b5cb

Browse files
phernandezclaude
andcommitted
feat: edit_note append/prepend auto-create note if not found (#614)
When append or prepend targets a non-existent note, the tool now creates the file automatically instead of returning an error. This eliminates silent failures for plugins (like openclaw) that use edit_note(append) to build daily conversation notes — on the first message of each day, the note didn't exist yet. - find_replace and replace_section still require an existing note - JSON output now includes `fileCreated: bool` in all responses - Path traversal security check applied to auto-created directories - Updated error messages to suggest append/prepend for missing notes 🧪 25 unit tests, 14 MCP integration tests, 10 CLI integration tests — all passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent f0335b9 commit e59b5cb

4 files changed

Lines changed: 389 additions & 59 deletions

File tree

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 155 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,35 @@
77

88
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
99
from basic_memory.mcp.server import mcp
10+
from basic_memory.schemas.base import Entity
11+
from basic_memory.utils import validate_project_path
12+
13+
14+
def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]:
15+
"""Parse an identifier into (title, directory) for creating a new note.
16+
17+
Strips memory:// prefix if present, then splits on the last '/' to
18+
separate the directory path from the note title.
19+
20+
Examples:
21+
"conversations/my-note" → ("my-note", "conversations")
22+
"my-note" → ("my-note", "")
23+
"a/b/c/my-note" → ("my-note", "a/b/c")
24+
"memory://a/b/note" → ("note", "a/b")
25+
"""
26+
cleaned = identifier
27+
if cleaned.startswith("memory://"):
28+
cleaned = cleaned[len("memory://"):]
29+
30+
if "/" in cleaned:
31+
last_slash = cleaned.rfind("/")
32+
directory = cleaned[:last_slash]
33+
title = cleaned[last_slash + 1:]
34+
else:
35+
directory = ""
36+
title = cleaned
37+
38+
return title, directory
1039

1140

1241
def _format_error_response(
@@ -19,15 +48,19 @@ def _format_error_response(
1948
) -> str:
2049
"""Format helpful error responses for edit_note failures that guide the AI to retry successfully."""
2150

22-
# Entity not found errors
51+
# Entity not found errors — only reachable for find_replace/replace_section
52+
# because append/prepend auto-create the note when it doesn't exist
2353
if "Entity not found" in error_message or "entity not found" in error_message.lower():
2454
return f"""# Edit Failed - Note Not Found
2555
26-
The note with identifier '{identifier}' could not be found. Edit operations require an exact match (no fuzzy matching).
56+
The note with identifier '{identifier}' could not be found. The `find_replace` and `replace_section` operations require an existing note with content to modify.
57+
58+
**Tip:** `append` and `prepend` operations automatically create the note if it doesn't exist.
2759
2860
## Suggestions to try:
29-
1. **Search for the note first**: Use `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
30-
2. **Try different exact identifier formats**:
61+
1. **Use append/prepend instead**: These operations will create the note automatically if it doesn't exist
62+
2. **Search for the note first**: Use `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
63+
3. **Try different exact identifier formats**:
3164
- If you used a permalink like "folder/note-title", try the exact title: "{identifier.split("/")[-1].replace("-", " ").title()}"
3265
- If you used a title, try the exact permalink format: "{identifier.lower().replace(" ", "-")}"
3366
- Use `read_note("{project or "project-name"}", "{identifier}")` first to verify the note exists and get the exact identifier
@@ -152,10 +185,10 @@ async def edit_note(
152185
Must be an exact match - fuzzy matching is not supported for edit operations.
153186
Use search_notes() or read_note() first to find the correct identifier if uncertain.
154187
operation: The editing operation to perform:
155-
- "append": Add content to the end of the note
156-
- "prepend": Add content to the beginning of the note
157-
- "find_replace": Replace occurrences of find_text with content
158-
- "replace_section": Replace content under a specific markdown header
188+
- "append": Add content to the end of the note (creates the note if it doesn't exist)
189+
- "prepend": Add content to the beginning of the note (creates the note if it doesn't exist)
190+
- "find_replace": Replace occurrences of find_text with content (note must exist)
191+
- "replace_section": Replace content under a specific markdown header (note must exist)
159192
content: The content to add or use for replacement
160193
project: Project name to edit in. Optional - server will resolve using hierarchy.
161194
If unknown, use list_memory_projects() to discover available projects.
@@ -243,48 +276,118 @@ async def edit_note(
243276
# Use typed KnowledgeClient for API calls
244277
knowledge_client = KnowledgeClient(client, active_project.external_id)
245278

246-
# Resolve identifier to entity ID
247-
entity_id = await knowledge_client.resolve_entity(identifier)
248-
249-
# Prepare the edit request data
250-
edit_data = {
251-
"operation": operation,
252-
"content": content,
253-
}
254-
255-
# Add optional parameters
256-
if section:
257-
edit_data["section"] = section
258-
if find_text:
259-
edit_data["find_text"] = find_text
260-
if effective_replacements != 1: # Only send if different from default
261-
edit_data["expected_replacements"] = str(effective_replacements)
262-
263-
# Call the PATCH endpoint
264-
result = await knowledge_client.patch_entity(entity_id, edit_data, fast=False)
265-
266-
# Format summary
267-
summary = [
268-
f"# Edited note ({operation})",
269-
f"project: {active_project.name}",
270-
f"file_path: {result.file_path}",
271-
f"permalink: {result.permalink}",
272-
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
273-
]
274-
275-
# Add operation-specific details
276-
if operation == "append":
277-
lines_added = len(content.split("\n"))
278-
summary.append(f"operation: Added {lines_added} lines to end of note")
279-
elif operation == "prepend":
279+
file_created = False
280+
281+
# Try to resolve the entity; for append/prepend, create it if not found
282+
try:
283+
entity_id = await knowledge_client.resolve_entity(identifier)
284+
except Exception as resolve_error:
285+
# Trigger: entity does not exist yet
286+
# Why: append/prepend can meaningfully create a new note from the content,
287+
# while find_replace/replace_section require existing content to modify
288+
# Outcome: note is created via the same path as write_note
289+
error_msg = str(resolve_error).lower()
290+
is_not_found = "entity not found" in error_msg or "not found" in error_msg
291+
292+
if is_not_found and operation in ("append", "prepend"):
293+
title, directory = _parse_identifier_to_title_and_directory(identifier)
294+
295+
# Validate directory path (same security check as write_note)
296+
project_path = active_project.home
297+
if directory and not validate_project_path(directory, project_path):
298+
logger.warning(
299+
"Attempted path traversal attack blocked",
300+
directory=directory,
301+
project=active_project.name,
302+
)
303+
if output_format == "json":
304+
return {
305+
"title": title,
306+
"permalink": None,
307+
"file_path": None,
308+
"checksum": None,
309+
"operation": operation,
310+
"fileCreated": False,
311+
"error": "SECURITY_VALIDATION_ERROR",
312+
}
313+
return f"# Error\n\nDirectory path '{directory}' is not allowed - paths must stay within project boundaries"
314+
315+
entity = Entity(
316+
title=title,
317+
directory=directory,
318+
content_type="text/markdown",
319+
content=content,
320+
)
321+
322+
logger.info(
323+
"Creating note via edit_note auto-create",
324+
title=title,
325+
directory=directory,
326+
operation=operation,
327+
)
328+
result = await knowledge_client.create_entity(
329+
entity.model_dump(), fast=False
330+
)
331+
file_created = True
332+
else:
333+
# find_replace/replace_section require existing content — re-raise
334+
raise resolve_error
335+
336+
# --- Standard edit path (entity already existed) ---
337+
if not file_created:
338+
# Prepare the edit request data
339+
edit_data = {
340+
"operation": operation,
341+
"content": content,
342+
}
343+
344+
# Add optional parameters
345+
if section:
346+
edit_data["section"] = section
347+
if find_text:
348+
edit_data["find_text"] = find_text
349+
if effective_replacements != 1: # Only send if different from default
350+
edit_data["expected_replacements"] = str(effective_replacements)
351+
352+
# Call the PATCH endpoint
353+
result = await knowledge_client.patch_entity(entity_id, edit_data, fast=False)
354+
355+
# --- Format response ---
356+
if file_created:
357+
summary = [
358+
f"# Created note ({operation})",
359+
f"project: {active_project.name}",
360+
f"file_path: {result.file_path}",
361+
f"permalink: {result.permalink}",
362+
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
363+
"fileCreated: true",
364+
]
280365
lines_added = len(content.split("\n"))
281-
summary.append(f"operation: Added {lines_added} lines to beginning of note")
282-
elif operation == "find_replace":
283-
# For find_replace, we can't easily count replacements from here
284-
# since we don't have the original content, but the server handled it
285-
summary.append("operation: Find and replace operation completed")
286-
elif operation == "replace_section":
287-
summary.append(f"operation: Replaced content under section '{section}'")
366+
summary.append(f"operation: Created note with {lines_added} lines")
367+
else:
368+
summary = [
369+
f"# Edited note ({operation})",
370+
f"project: {active_project.name}",
371+
f"file_path: {result.file_path}",
372+
f"permalink: {result.permalink}",
373+
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
374+
]
375+
376+
# Add operation-specific details
377+
if operation == "append":
378+
lines_added = len(content.split("\n"))
379+
summary.append(f"operation: Added {lines_added} lines to end of note")
380+
elif operation == "prepend":
381+
lines_added = len(content.split("\n"))
382+
summary.append(
383+
f"operation: Added {lines_added} lines to beginning of note"
384+
)
385+
elif operation == "find_replace":
386+
# For find_replace, we can't easily count replacements from here
387+
# since we don't have the original content, but the server handled it
388+
summary.append("operation: Find and replace operation completed")
389+
elif operation == "replace_section":
390+
summary.append(f"operation: Replaced content under section '{section}'")
288391

289392
# Count observations by category (reuse logic from write_note)
290393
categories = {}
@@ -316,6 +419,7 @@ async def edit_note(
316419
permalink=result.permalink,
317420
observations_count=len(result.observations),
318421
relations_count=len(result.relations),
422+
file_created=file_created,
319423
)
320424

321425
if output_format == "json":
@@ -325,6 +429,7 @@ async def edit_note(
325429
"file_path": result.file_path,
326430
"checksum": result.checksum,
327431
"operation": operation,
432+
"fileCreated": file_created,
328433
}
329434

330435
summary_result = "\n".join(summary)
@@ -339,6 +444,7 @@ async def edit_note(
339444
"file_path": None,
340445
"checksum": None,
341446
"operation": operation,
447+
"fileCreated": False,
342448
"error": str(e),
343449
}
344450
return _format_error_response(

test-int/cli/test_cli_tool_edit_note_integration.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,34 @@ def test_edit_note_replace_section_fails_without_section(
211211
assert "section parameter is required for replace_section operation" in result.output
212212

213213

214+
def test_edit_note_append_creates_nonexistent_note_cli(
215+
app, app_config, test_project, config_manager
216+
):
217+
"""append to a non-existent note via CLI should auto-create and include fileCreated."""
218+
result = runner.invoke(
219+
cli_app,
220+
[
221+
"tool",
222+
"edit-note",
223+
"cli-tests/auto-created-note",
224+
"--operation",
225+
"append",
226+
"--content",
227+
"# Auto Created\n\nCreated via CLI append.",
228+
],
229+
)
230+
231+
assert result.exit_code == 0, result.output
232+
data = json.loads(result.stdout)
233+
assert data["fileCreated"] is True
234+
assert data["operation"] == "append"
235+
assert data["title"] is not None
236+
237+
# Verify the note is readable
238+
read_data = _read_note(data["permalink"])
239+
assert "Auto Created" in read_data["content"]
240+
241+
214242
def test_edit_note_json_format_contract(app, app_config, test_project, config_manager):
215243
"""JSON output returns metadata keys required by contract."""
216244
note = _write_note(
@@ -234,8 +262,9 @@ def test_edit_note_json_format_contract(app, app_config, test_project, config_ma
234262

235263
assert result.exit_code == 0, result.output
236264
data = json.loads(result.stdout)
237-
assert set(data.keys()) == {"title", "permalink", "file_path", "operation", "checksum"}
265+
assert set(data.keys()) == {"title", "permalink", "file_path", "operation", "checksum", "fileCreated"}
238266
assert data["operation"] == "append"
267+
assert data["fileCreated"] is False
239268
assert data["title"] == "Edit JSON Note"
240269

241270

0 commit comments

Comments
 (0)