Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/basic_memory/file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,12 +431,16 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
raise


def remove_frontmatter(content: str) -> str:
def remove_frontmatter(content: str, *, strip: bool = True) -> str:
"""
Remove YAML frontmatter from content.

Args:
content: Content with frontmatter
strip: When True (default), strip surrounding whitespace from the returned
body — the historical behavior. Pass False to get the body exactly as
it appears after the closing fence; frontmatter-only rewrites must not
reflow the note body (issue #1090 review).

Returns:
Content with frontmatter removed, or original content if no frontmatter
Expand All @@ -452,10 +456,10 @@ def remove_frontmatter(content: str) -> str:
# Why: inline `---` is ordinary content, not frontmatter (issue #972)
# Outcome: return the content untouched (stripped to preserve prior behavior)
if split is None:
return content.strip()
return content.strip() if strip else content

_, body = split
return body.strip()
return body.strip() if strip else body


def dump_frontmatter(post: frontmatter.Post) -> str:
Expand Down
1 change: 1 addition & 0 deletions src/basic_memory/indexing/accepted_note_mutation_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ async def _run_accepted_note_edit(
expected_replacements=request.data.expected_replacements,
replace_subsections=request.data.replace_subsections,
user_profile_value=user_profile_value,
metadata=request.data.metadata,
)
except (ParseError, ValueError) as error:
reject_accepted_note_mutation(AcceptedNoteMutationRejectKind.bad_request, str(error))
Expand Down
5 changes: 4 additions & 1 deletion src/basic_memory/indexing/accepted_note_write_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from typing import Any, Protocol

from sqlalchemy.ext.asyncio import AsyncSession

Expand Down Expand Up @@ -88,6 +88,7 @@ async def prepare_edit_entity_content(
find_text: str | None = ...,
expected_replacements: int = ...,
replace_subsections: bool = ...,
metadata: dict[str, Any] | None = ...,
session: AsyncSession | None = ...,
) -> PreparedEntityWrite: ...

Expand Down Expand Up @@ -310,6 +311,7 @@ async def prepare_accepted_note_edit(
expected_replacements: int,
replace_subsections: bool,
user_profile_value: str | None,
metadata: dict[str, Any] | None = None,
) -> AcceptedPreparedNoteWrite:
"""Prepare a partial accepted edit and apply its entity fields."""
prepared = await preparer.prepare_edit_entity_content(
Expand All @@ -321,6 +323,7 @@ async def prepare_accepted_note_edit(
find_text=find_text,
expected_replacements=expected_replacements,
replace_subsections=replace_subsections,
metadata=metadata,
session=session,
)
result = AcceptedPreparedNoteWrite(
Expand Down
33 changes: 30 additions & 3 deletions src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from pydantic import AliasChoices, Field
from pydantic import AliasChoices, BeforeValidator, Field

if TYPE_CHECKING: # pragma: no cover
from basic_memory.mcp.clients import KnowledgeClient
Expand All @@ -30,7 +30,7 @@
detect_project_from_workspace_identifier_prefix,
is_workspace_qualified_plain_identifier,
)
from basic_memory.utils import normalize_project_reference, validate_project_path
from basic_memory.utils import coerce_dict, normalize_project_reference, validate_project_path

EDIT_OPERATIONS = (
"append",
Expand Down Expand Up @@ -347,7 +347,7 @@ def _format_error_response(

@mcp.tool(
title="Edit Note",
description="Edit an existing markdown note using various operations like append, prepend, find_replace, replace_section, insert_before_section, or insert_after_section.",
description="Edit an existing markdown note using various operations like append, prepend, find_replace, replace_section, insert_before_section, or insert_after_section. Pass metadata to merge YAML frontmatter fields independent of the operation.",
tags={"notes"},
annotations={
"title": "Edit Note",
Expand Down Expand Up @@ -391,6 +391,7 @@ async def edit_note(
] = None,
expected_replacements: Optional[int] = None,
replace_subsections: Optional[bool] = None,
metadata: Annotated[Optional[dict], BeforeValidator(coerce_dict)] = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
) -> str | dict:
Expand Down Expand Up @@ -433,6 +434,12 @@ async def edit_note(
the replacement content may freely introduce new headings. Set to false to
replace only the immediate content under the header, stopping at the next
heading of any level and preserving subsections.
metadata: Optional dict of frontmatter fields to merge, independent of `operation`.
Provided keys overwrite existing frontmatter values (or are added if new);
unrelated frontmatter keys and the note body are left untouched. Can be
combined with any operation in the same call. `title`, `type`, and `permalink`
are ignored since those have their own dedicated handling. Key deletion is
not supported.
output_format: "text" returns the existing markdown summary. "json" returns
machine-readable edit metadata.
context: Optional FastMCP context for performance caching.
Expand Down Expand Up @@ -480,6 +487,11 @@ async def edit_note(
# Update status across document (expecting exactly 2 occurrences)
edit_note("reports", "status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)

# Update frontmatter fields without touching the body (any operation works;
# append with empty content is a no-op on the body itself)
edit_note("support", "tickets/2026-06-18-printer-offline", "append", "",
metadata={"status": "resolved", "closed_at": "2026-06-18T10:42:00Z"})

Raises:
HTTPError: If project doesn't exist or is inaccessible
ValueError: If operation is invalid or required parameters are missing
Expand Down Expand Up @@ -557,6 +569,7 @@ async def edit_note(
has_find_text=bool(find_text),
expected_replacements=effective_replacements,
replace_subsections=effective_replace_subsections,
has_metadata=bool(metadata),
):
async with get_project_client(project, context=context, project_id=project_id) as (
client,
Expand All @@ -579,6 +592,17 @@ async def edit_note(
section_ops = ("replace_section", "insert_before_section", "insert_after_section")
if operation in section_ops and not section:
raise ValueError("section parameter is required for section-based operations")
# Reject null metadata values before dispatch so both the edit path and the
# append/prepend auto-create fallback behave identically — the service-side
# guard only covers existing notes, and an auto-created note would otherwise
# be written with a YAML null that indexing silently filters out.
if metadata:
null_keys = sorted(k for k, v in metadata.items() if v is None)
if null_keys:
raise ValueError(
"metadata values cannot be null (key deletion is not supported): "
+ ", ".join(null_keys)
)

# Use the PATCH endpoint to edit the entity
try:
Expand Down Expand Up @@ -696,6 +720,7 @@ async def edit_note(
directory=directory,
content_type="text/markdown",
content=content,
entity_metadata=metadata,
Comment thread
phernandez marked this conversation as resolved.
)

logger.info(
Expand Down Expand Up @@ -727,6 +752,8 @@ async def edit_note(
edit_data["expected_replacements"] = str(effective_replacements)
if not effective_replace_subsections: # Only send if different from default
edit_data["replace_subsections"] = False
if metadata:
edit_data["metadata"] = metadata

# Call the PATCH endpoint
result = await knowledge_client.patch_entity(entity_id, edit_data)
Expand Down
6 changes: 5 additions & 1 deletion src/basic_memory/schemas/request.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Request schemas for interacting with the knowledge graph."""

from typing import List, Optional, Annotated, Literal
from typing import Any, List, Optional, Annotated, Literal
from annotated_types import MaxLen, MinLen

from pydantic import BaseModel, field_validator
Expand Down Expand Up @@ -81,6 +81,10 @@ class EditEntityRequest(BaseModel):
# level-aware section including subsections; False stops at the first heading
# of any level, preserving them.
replace_subsections: bool = True
# Frontmatter fields to merge, independent of `operation` (issue #1011). Set/overwrite
# semantics: provided keys overwrite existing values, unrelated keys and the body are
# untouched. title/type/permalink are ignored — they have their own resolution paths.
metadata: Optional[dict[str, Any]] = None

@field_validator("section")
@classmethod
Expand Down
8 changes: 7 additions & 1 deletion src/basic_memory/services/entity_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Sequence, Tuple, Union
from typing import Any, List, Optional, Sequence, Tuple, Union

from loguru import logger
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
Expand Down Expand Up @@ -264,6 +264,7 @@ async def prepare_edit_entity_content(
find_text: Optional[str] = None,
expected_replacements: int = 1,
replace_subsections: bool = True,
metadata: Optional[dict[str, Any]] = None,
skip_conflict_check: bool = False,
session: AsyncSession | None = None,
) -> PreparedEntityWrite:
Expand All @@ -273,6 +274,10 @@ async def prepare_edit_entity_content(
does not read files, write files, or mutate database rows. That makes the
edit base explicit so higher layers can reject stale content instead of
silently editing whichever storage copy happens to be newest.

``metadata``, when provided, is merged into the note's YAML frontmatter
independent of ``operation``. Null values are rejected — key deletion is
not supported.
"""
return await self._note_preparation.prepare_edit_entity_content(
entity,
Expand All @@ -283,6 +288,7 @@ async def prepare_edit_entity_content(
find_text=find_text,
expected_replacements=expected_replacements,
replace_subsections=replace_subsections,
metadata=metadata,
skip_conflict_check=skip_conflict_check,
session=session,
)
Expand Down
92 changes: 83 additions & 9 deletions src/basic_memory/services/note_preparation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any

import frontmatter
import yaml
Expand Down Expand Up @@ -648,6 +649,64 @@ def apply_edit_operation(
raise ValueError(f"Unsupported operation: {operation}")


# title/type/permalink already have dedicated resolution paths in
# prepare_edit_entity_content (H1 title reconciliation, permalink resolver). Letting a
# metadata merge touch them would race with those paths and could be silently reverted.
_METADATA_IDENTITY_FIELDS = frozenset({"title", "type", "permalink"})


def _merge_metadata_into_markdown(markdown_content: str, metadata: dict[str, Any]) -> str:
"""Merge caller-supplied fields into a markdown string's YAML frontmatter.

Identity fields (title/type/permalink) are dropped from the merge; every other key
overwrites the existing frontmatter value or is added new. The note body, and any
frontmatter keys not present in ``metadata``, are left untouched.
"""
null_keys = sorted(key for key, value in metadata.items() if value is None)
if null_keys:
# A null value would be filtered out of the indexed entity metadata right after
# this merge, silently losing the field even though key deletion is unsupported.
raise ValueError(
"metadata values cannot be null (key deletion is not supported): "
+ ", ".join(null_keys)
)
sanitized = {k: v for k, v in metadata.items() if k not in _METADATA_IDENTITY_FIELDS}
if not sanitized:
return markdown_content

had_separator = True
if has_frontmatter(markdown_content):
current_metadata = parse_frontmatter(markdown_content)
# strip=False: a frontmatter-only rewrite must not reflow the body (leading
# blank lines, trailing hard-break spaces). dump_frontmatter re-inserts the
# single blank separator line after the closing fence, so drop exactly one
# leading newline here to round-trip the body unchanged.
body = remove_frontmatter(markdown_content, strip=False)
if body.startswith("\r\n"):
body = body[2:]
elif body.startswith("\n"):
body = body[1:]
else:
had_separator = False
else:
current_metadata = {}
body = markdown_content

merged_metadata = deepcopy(current_metadata)
merged_metadata.update(sanitized)

post = frontmatter.Post(body)
post.metadata.update(merged_metadata)
if not had_separator and body:
# Trigger: the original note had no blank line between the closing fence and
# the body ("---\nBody"), but dump_frontmatter always emits one.
# Outcome: serialize the frontmatter alone and reattach the body verbatim so
# a frontmatter-only merge cannot insert a blank line into the body.
post.content = ""
return dump_frontmatter(post) + body
return dump_frontmatter(post)
Comment thread
phernandez marked this conversation as resolved.


async def prepare_edit_entity_content(
dependencies: NotePreparationDependencies,
entity: Entity,
Expand All @@ -659,19 +718,32 @@ async def prepare_edit_entity_content(
find_text: str | None = None,
expected_replacements: int = 1,
replace_subsections: bool = True,
metadata: dict[str, Any] | None = None,
skip_conflict_check: bool = False,
session: AsyncSession | None = None,
) -> PreparedEntityWrite:
file_path = Path(entity.file_path)
markdown_content = apply_edit_operation(
current_content,
operation,
content,
section,
find_text,
expected_replacements,
replace_subsections,
)
# Trigger: the documented metadata-only pattern is empty append/prepend plus metadata.
# Why: an empty append still appends "\n" to a body without a trailing newline, so a
# request advertised as frontmatter-only would mutate the body it promised to keep.
# Outcome: skip the content operation entirely and merge into the current text.
metadata_only_edit = bool(metadata) and operation in ("append", "prepend") and not content
if metadata_only_edit:
markdown_content = current_content
else:
markdown_content = apply_edit_operation(
current_content,
operation,
content,
section,
find_text,
expected_replacements,
replace_subsections,
)
# Merge before frontmatter-derived resolution below so merged keys land in the
# indexed entity metadata in the same pass — see _merge_metadata_into_markdown.
if metadata:
markdown_content = _merge_metadata_into_markdown(markdown_content, metadata)
title = entity.title
note_type = entity.note_type
permalink = entity.permalink
Expand Down Expand Up @@ -876,6 +948,7 @@ async def prepare_edit_entity_content(
find_text: str | None = None,
expected_replacements: int = 1,
replace_subsections: bool = True,
metadata: dict[str, Any] | None = None,
skip_conflict_check: bool = False,
session: AsyncSession | None = None,
) -> PreparedEntityWrite:
Expand All @@ -889,6 +962,7 @@ async def prepare_edit_entity_content(
find_text=find_text,
expected_replacements=expected_replacements,
replace_subsections=replace_subsections,
metadata=metadata,
skip_conflict_check=skip_conflict_check,
session=session,
)
Expand Down
Loading
Loading