Skip to content

Commit 9259a7e

Browse files
phernandezclaude
andauthored
feat: min-similarity override, edit-note CLI, and strip-frontmatter (#571)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 55d675e commit 9259a7e

7 files changed

Lines changed: 803 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# CHANGELOG
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- Add `--strip-frontmatter` to `basic-memory tool read-note`
8+
- Default behavior is unchanged: `content` still includes raw markdown with frontmatter.
9+
- With `--strip-frontmatter`, both text and JSON modes return body-only markdown content.
10+
- JSON output now includes an additive `frontmatter` field with parsed YAML metadata (or `null`
11+
when no valid opening frontmatter block exists).
12+
313
## v0.18.3 (2026-02-12)
414

515
### Bug Fixes

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,22 @@ basic-memory project info my-project --cloud
393393

394394
The local MCP server (`basic-memory mcp`) automatically uses local routing, so you can use both local Claude Desktop and cloud-based clients simultaneously.
395395

396+
**CLI Note Editing (`tool edit-note`):**
397+
398+
```bash
399+
# Append content
400+
basic-memory tool edit-note project-plan --operation append --content $'\n## Next Steps\n- Finalize rollout'
401+
402+
# Find/replace with replacement count validation
403+
basic-memory tool edit-note docs/api --operation find_replace --find-text "v0.14.0" --content "v0.15.0" --expected-replacements 2
404+
405+
# Replace a section body
406+
basic-memory tool edit-note docs/setup --operation replace_section --section "## Installation" --content $'Updated install steps\n- Run just install'
407+
408+
# JSON metadata output for integrations
409+
basic-memory tool edit-note docs/setup --operation append --content $'\n- Added note' --format json
410+
```
411+
396412
4. In Claude Desktop, the LLM can now use these tools:
397413

398414
**Content Management:**

src/basic_memory/cli/commands/tool.py

Lines changed: 216 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import json
44
import sys
5-
from typing import Annotated, List, Optional
5+
from typing import Annotated, Any, List, Optional
66

77
import typer
8+
import yaml
89
from loguru import logger
910
from rich import print as rprint
1011

@@ -28,6 +29,7 @@
2829
recent_activity_prompt as recent_activity_prompt,
2930
)
3031
from basic_memory.mcp.tools import build_context as mcp_build_context
32+
from basic_memory.mcp.tools import edit_note as mcp_edit_note
3133
from basic_memory.mcp.tools import read_note as mcp_read_note
3234
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
3335
from basic_memory.mcp.tools import search_notes as mcp_search
@@ -36,6 +38,60 @@
3638
tool_app = typer.Typer()
3739
app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
3840

41+
VALID_EDIT_OPERATIONS = ["append", "prepend", "find_replace", "replace_section"]
42+
43+
44+
# --- Frontmatter helpers ---
45+
46+
47+
def _parse_opening_frontmatter(content: str) -> tuple[str, dict[str, Any] | None]:
48+
"""Parse and strip an opening YAML frontmatter block if valid.
49+
50+
Returns a tuple of (body_content_or_original, parsed_frontmatter_or_none).
51+
52+
Behavior:
53+
- Only parses frontmatter if the first line is an opening '---' delimiter.
54+
- Requires a closing '---' delimiter.
55+
- Accepts mapping YAML only; malformed or non-mapping YAML is ignored.
56+
- Supports UTF-8 BOM at document start.
57+
"""
58+
if not content:
59+
return content, None
60+
61+
original_content = content
62+
if content.startswith("\ufeff"):
63+
content = content[1:]
64+
65+
lines = content.splitlines(keepends=True)
66+
if not lines:
67+
return original_content, None
68+
69+
if lines[0].rstrip("\r\n").strip() != "---":
70+
return original_content, None
71+
72+
closing_index = None
73+
for index in range(1, len(lines)):
74+
if lines[index].rstrip("\r\n").strip() == "---":
75+
closing_index = index
76+
break
77+
78+
if closing_index is None:
79+
return original_content, None
80+
81+
frontmatter_text = "".join(lines[1:closing_index])
82+
try:
83+
parsed = yaml.safe_load(frontmatter_text) if frontmatter_text else {}
84+
except yaml.YAMLError:
85+
return original_content, None
86+
87+
if parsed is None:
88+
parsed = {}
89+
if not isinstance(parsed, dict):
90+
return original_content, None
91+
92+
body_content = "".join(lines[closing_index + 1 :])
93+
return body_content, parsed
94+
3995

4096
# --- JSON output helpers ---
4197
# These async functions bypass the MCP tool (which returns formatted strings)
@@ -111,6 +167,61 @@ async def _read_note_json(
111167
}
112168

113169

170+
async def _edit_note_json(
171+
identifier: str,
172+
operation: str,
173+
content: str,
174+
project_name: Optional[str],
175+
section: Optional[str],
176+
find_text: Optional[str],
177+
expected_replacements: int,
178+
) -> dict:
179+
"""Edit a note and return structured JSON metadata."""
180+
async with get_client(project_name=project_name) as client:
181+
active_project = await get_active_project(client, project_name)
182+
knowledge_client = KnowledgeClient(client, active_project.external_id)
183+
184+
entity_id = await knowledge_client.resolve_entity(identifier)
185+
186+
edit_data: dict[str, Any] = {
187+
"operation": operation,
188+
"content": content,
189+
"expected_replacements": expected_replacements,
190+
}
191+
if section:
192+
edit_data["section"] = section
193+
if find_text:
194+
edit_data["find_text"] = find_text
195+
196+
result = await knowledge_client.patch_entity(entity_id, edit_data, fast=False)
197+
return {
198+
"title": result.title,
199+
"permalink": result.permalink,
200+
"file_path": result.file_path,
201+
"operation": operation,
202+
"checksum": result.checksum,
203+
}
204+
205+
206+
def _validate_edit_note_args(
207+
operation: str, find_text: Optional[str], section: Optional[str]
208+
) -> None:
209+
"""Validate operation-specific required arguments for edit-note."""
210+
if operation not in VALID_EDIT_OPERATIONS:
211+
raise ValueError(
212+
f"Invalid operation '{operation}'. Must be one of: {', '.join(VALID_EDIT_OPERATIONS)}"
213+
)
214+
if operation == "find_replace" and not find_text:
215+
raise ValueError("find_text parameter is required for find_replace operation")
216+
if operation == "replace_section" and not section:
217+
raise ValueError("section parameter is required for replace_section operation")
218+
219+
220+
def _is_edit_note_failure_response(result: str) -> bool:
221+
"""Check whether the MCP edit_note text response indicates a failed edit."""
222+
return result.lstrip().startswith("# Edit Failed")
223+
224+
114225
async def _recent_activity_json(
115226
type: Optional[List[SearchItemType]],
116227
depth: Optional[int],
@@ -281,6 +392,14 @@ def read_note(
281392
page: int = 1,
282393
page_size: int = 10,
283394
format: str = typer.Option("text", "--format", help="Output format: text or json"),
395+
strip_frontmatter: bool = typer.Option(
396+
False,
397+
"--strip-frontmatter",
398+
help=(
399+
"Strip opening YAML frontmatter from content. "
400+
"JSON output includes parsed frontmatter under 'frontmatter'."
401+
),
402+
),
284403
local: bool = typer.Option(
285404
False, "--local", help="Force local API routing (ignore cloud mode)"
286405
),
@@ -290,6 +409,7 @@ def read_note(
290409
291410
Use --local to force local routing when cloud mode is enabled.
292411
Use --cloud to force cloud routing when cloud mode is disabled.
412+
Use --strip-frontmatter to return body-only markdown content.
293413
"""
294414
try:
295415
validate_routing_flags(local, cloud)
@@ -311,9 +431,15 @@ def read_note(
311431
result = run_with_cleanup(
312432
_read_note_json(identifier, project_name, page, page_size)
313433
)
434+
stripped_content, parsed_frontmatter = _parse_opening_frontmatter(result["content"])
435+
result["frontmatter"] = parsed_frontmatter
436+
if strip_frontmatter:
437+
result["content"] = stripped_content
314438
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
315439
else:
316440
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
441+
if strip_frontmatter:
442+
note, _ = _parse_opening_frontmatter(note)
317443
rprint(note)
318444
except ValueError as e:
319445
typer.echo(f"Error: {e}", err=True)
@@ -325,6 +451,95 @@ def read_note(
325451
raise
326452

327453

454+
@tool_app.command()
455+
def edit_note(
456+
identifier: str,
457+
operation: Annotated[str, typer.Option("--operation", help="Edit operation to apply")],
458+
content: Annotated[str, typer.Option("--content", help="Content for the edit operation")],
459+
project: Annotated[
460+
Optional[str],
461+
typer.Option(
462+
help="The project to edit. If not provided, the default project will be used."
463+
),
464+
] = None,
465+
find_text: Annotated[
466+
Optional[str], typer.Option("--find-text", help="Text to find for find_replace operation")
467+
] = None,
468+
section: Annotated[
469+
Optional[str],
470+
typer.Option("--section", help="Section heading for replace_section operation"),
471+
] = None,
472+
expected_replacements: int = typer.Option(
473+
1,
474+
"--expected-replacements",
475+
help="Expected replacement count for find_replace operation",
476+
),
477+
format: str = typer.Option("text", "--format", help="Output format: text or json"),
478+
local: bool = typer.Option(
479+
False, "--local", help="Force local API routing (ignore cloud mode)"
480+
),
481+
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
482+
):
483+
"""Edit an existing markdown note using append/prepend/find_replace/replace_section.
484+
485+
Use --local to force local routing when cloud mode is enabled.
486+
Use --cloud to force cloud routing when cloud mode is disabled.
487+
"""
488+
try:
489+
validate_routing_flags(local, cloud)
490+
_validate_edit_note_args(operation, find_text, section)
491+
492+
# look for the project in the config
493+
config_manager = ConfigManager()
494+
project_name = None
495+
if project is not None:
496+
project_name, _ = config_manager.get_project(project)
497+
if not project_name:
498+
typer.echo(f"No project found named: {project}", err=True)
499+
raise typer.Exit(1)
500+
501+
# use the project name, or the default from the config
502+
project_name = project_name or config_manager.default_project
503+
504+
with force_routing(local=local, cloud=cloud):
505+
if format == "json":
506+
result = run_with_cleanup(
507+
_edit_note_json(
508+
identifier=identifier,
509+
operation=operation,
510+
content=content,
511+
project_name=project_name,
512+
section=section,
513+
find_text=find_text,
514+
expected_replacements=expected_replacements,
515+
)
516+
)
517+
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
518+
else:
519+
result = run_with_cleanup(
520+
mcp_edit_note.fn(
521+
identifier=identifier,
522+
operation=operation,
523+
content=content,
524+
project=project_name,
525+
section=section,
526+
find_text=find_text,
527+
expected_replacements=expected_replacements,
528+
)
529+
)
530+
rprint(result)
531+
if _is_edit_note_failure_response(result):
532+
raise typer.Exit(1)
533+
except ValueError as e:
534+
typer.echo(f"Error: {e}", err=True)
535+
raise typer.Exit(1)
536+
except Exception as e: # pragma: no cover
537+
if not isinstance(e, typer.Exit):
538+
typer.echo(f"Error during edit_note: {e}", err=True)
539+
raise typer.Exit(1)
540+
raise
541+
542+
328543
@tool_app.command()
329544
def build_context(
330545
url: MemoryUrl,

0 commit comments

Comments
 (0)