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
24 changes: 22 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@
(the full `checksum` from a JSON `read_note`, `write_note` or `edit_note` result) and
the edit applies only while the note is still that revision. Otherwise nothing is
written and the tool error is a revision conflict carrying the current checksum (JSON
`error: "NOTE_REVISION_CONFLICT"`, `currentChecksum`). A guarded `append` or `prepend`
`error: "NOTE_REVISION_CONFLICT"`, `currentChecksum`, with the note's title, permalink
and file path while it still exists, the same shape as `write_note`'s, #1719). A guarded
`append` or `prepend`
on a missing note is a conflict, not an auto-create. The v2 `PATCH` entity route
accepts the same `x-bm-cloud-note-base-checksum` header the `PUT` route does.

Expand All @@ -112,7 +114,9 @@
`expected_checksum` with `overwrite=True` (CLI: `--overwrite --expected-checksum`)
and the note is replaced only while it is still that revision. Otherwise nothing
changes and the result is a revision conflict carrying the current checksum (JSON
`error: "NOTE_REVISION_CONFLICT"`). A checksum for a path no note owns is also a
`error: "NOTE_REVISION_CONFLICT"` and `currentChecksum`, with `checksum` null because
nothing was written, and the note at the path named by its stored title and permalink,
#1719). A checksum for a path no note owns is also a
conflict, so a note deleted since you read it is not recreated. Without
`expected_checksum`, `overwrite=True` still replaces unconditionally.

Expand Down Expand Up @@ -361,6 +365,22 @@
`bisync-reset` now print only their own deprecation notice, without Click's extra
"DeprecationWarning: The command ... is deprecated." line.

- **#1716**: A note's `created_at` no longer resets on every edit, move or reindex.
Without a frontmatter `created`, the parser falls back to the file's ctime, which each
atomic rewrite or rename moves, and reindexing an existing note copied that time into
the row. Now an existing row keeps its creation time, and only a declared frontmatter
`created` changes it. A brand-new file still takes its file time.

- **#1717**: `build_context` text output names the source of relations the primary note
does not own. An incoming `relates_to` used to be listed bare under its target, so it
read as a self-link; it now reads `- [[Brewing Notes]] relates_to [[Bean Origins]]`.
Relations the note owns keep the bare form, and JSON output is unchanged.

- **#1720**: `edit_note` `insert_after_section` no longer puts a blank line between an
inserted list item and the section's list, which split one list in two. The item joins
the list directly, below any blank line under the heading. A paragraph inserted above
a paragraph still gets the blank line that keeps them apart.

- **#1595**: `basic_memory_diagnostics` lists the `BASIC_MEMORY_*` environment
variables that override `config.json`, redacted the same way as the file dump. It
used to print only the file, which can disagree with what the server is using. Other
Expand Down
9 changes: 7 additions & 2 deletions src/basic_memory/api/v2/routers/note_write_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,13 @@ async def write_note(
return NoteTargetMoved(**asdict(note))
case Locked(message=message):
return NoteLocked(message=message)
case RevisionConflict(file_path=file_path, current_db_checksum=current):
return NoteRevisionConflict(file_path=file_path, db_checksum=current)
case RevisionConflict(file_path=file_path, current_db_checksum=current, note=note):
return NoteRevisionConflict(
file_path=file_path,
db_checksum=current,
title=note.title if note else None,
permalink=note.permalink if note else None,
)
case Rejected(rejection=rejection):
error = note_content_mutation_error_from_rejection(rejection)
raise HTTPException(status_code=error.status_code, detail=error.detail)
Expand Down
3 changes: 2 additions & 1 deletion src/basic_memory/man/man3/write-note(3).md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ that replacement. Locked notes refuse replacement without changing their content
Pass `expected_checksum` with `overwrite=True` (CLI: `--expected-checksum`) to
make the replacement conditional on the revision you read. If the note changed
since, or no note owns the path any more, nothing is written and the tool
returns `NOTE_REVISION_CONFLICT` with the current checksum. Without it,
returns `NOTE_REVISION_CONFLICT` with the current checksum in `currentChecksum`,
the same shape as edit_note's conflict. Without it,
`overwrite=True` replaces the note unconditionally.

## PARAMETERS
Expand Down
14 changes: 13 additions & 1 deletion src/basic_memory/mcp/tools/build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,23 @@ def _format_entity_block(result: ContextResult, *, compact: bool = False) -> str
if relation_items:
lines.append("")
lines.append("### Relations")
primary_external_id = primary.external_id if isinstance(primary, EntitySummary) else None
for rel in relation_items:
# Unresolved forward references have no resolved entity yet; fall back
# to the literal target text instead of rendering [[None]] (#955)
target = rel.to_entity or rel.to_name
lines.append(f"- {rel.relation_type} [[{target}]]")
# Trigger: the relation is incoming, or belongs to a neighbor at depth > 1.
# Why: listed bare under this note, `relates_to [[This Note]]` reads as a
# self-link (#1717).
# Outcome: relations this note owns stay bare; others name their source.
is_outgoing = (
primary_external_id is not None
and rel.from_entity_external_id == primary_external_id
)
if is_outgoing or rel.from_entity is None:
lines.append(f"- {rel.relation_type} [[{target}]]")
else:
lines.append(f"- [[{rel.from_entity}]] {rel.relation_type} [[{target}]]")

# --- Related entities (non-relation related results) ---
related_entities: list[EntitySummary | ObservationSummary] = [
Expand Down
36 changes: 27 additions & 9 deletions src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import _extract_response_data, _response_detail_text
from basic_memory.schemas.base import Entity
from basic_memory.schemas.v2.entity import EntityResponseV2
from basic_memory.schemas.v2.entity import EntityResolveResponse, EntityResponseV2
from basic_memory.services.link_resolver import (
detect_project_from_workspace_identifier_prefix,
is_workspace_qualified_plain_identifier,
Expand Down Expand Up @@ -740,13 +740,16 @@ async def edit_note(
# Set once the create or PATCH request leaves this process; the handler below
# needs it to tell a refusal from a write whose outcome is unknown.
write_sent = False
# The note the edit targets; a revision conflict re-reads it by id (#1719).
target_entity_id: str | None = None
# Import here to avoid circular import
from basic_memory.mcp.clients import KnowledgeClient

# Use typed KnowledgeClient for API calls; the failure handler below also
# uses it to name the note in a revision conflict.
knowledge_client = KnowledgeClient(client, active_project.external_id)
# Use the PATCH endpoint to edit the entity
try:
# Import here to avoid circular import
from basic_memory.mcp.clients import KnowledgeClient

# Use typed KnowledgeClient for API calls
knowledge_client = KnowledgeClient(client, active_project.external_id)
unresolved_project_route: UnresolvedProjectRouteError | None = None
try:
_, entity_identifier, _ = await resolve_project_and_path(
Expand Down Expand Up @@ -807,6 +810,7 @@ async def edit_note(
),
)
entity_id = resolved_entity.external_id
target_entity_id = entity_id
except EditRefused:
# The cross-project refusal above must not be read as a missing note:
# its text says "Not Found", which would route it to auto-create.
Expand Down Expand Up @@ -1073,12 +1077,26 @@ async def edit_note(
conflict = _revision_conflict_detail(e) if expected_checksum is not None else None
if conflict is not None:
current_checksum = conflict.get("db_checksum")
# Same shape as write_note's conflict (#1719): the note is named while
# it still exists, as it reads now, since the winning write may have
# retitled or moved it after this edit resolved it. A deleted note
# (no current checksum) has no identity.
named_note: EntityResolveResponse | None = None
if current_checksum is not None and target_entity_id is not None:
try:
named_note = await knowledge_client.resolve_entity_response(
target_entity_id, strict=True
)
except ToolError:
# Deleted after the conflict: the refusal still stands and
# carries its checksum; there is just nothing left to name.
named_note = None
_raise_edit_failure(
output_format,
{
"title": None,
"permalink": None,
"file_path": None,
"title": named_note.title if named_note else None,
"permalink": named_note.permalink if named_note else None,
"file_path": named_note.file_path if named_note else None,
"checksum": None,
"operation": operation,
"fileCreated": False,
Expand Down
19 changes: 16 additions & 3 deletions src/basic_memory/mcp/tools/write_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,15 +558,28 @@ async def write_note(
case NoteLocked(message=message):
raise ToolError(message)
case NoteRevisionConflict(db_checksum=current_checksum) as conflict:
# Same shape as edit_note's conflict (#1719): nothing was written,
# so checksum is null; currentChecksum is the revision to retry
# against; identity names the note at the path, or is null once
# no note owns it.
conflict_permalink = conflict.permalink
conflict_workspace = current_workspace_permalink_context()
if conflict_permalink and conflict_workspace is not None:
conflict_permalink = build_qualified_permalink_reference(
active_project.permalink,
conflict_permalink,
workspace_permalink=conflict_workspace.workspace_slug,
)
_raise_write_refusal(
output_format,
{
"title": title,
"permalink": entity.permalink,
"title": conflict.title,
"permalink": conflict_permalink,
"file_path": conflict.file_path if current_checksum else None,
"checksum": current_checksum,
"checksum": None,
"action": "conflict",
"error": "NOTE_REVISION_CONFLICT",
"currentChecksum": current_checksum,
},
_format_revision_conflict(
title, conflict.file_path, current_checksum, active_project.name
Expand Down
4 changes: 4 additions & 0 deletions src/basic_memory/schemas/v2/note_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ class NoteRevisionConflict(BaseModel):
file_path: str
# None when no note owns the path any more.
db_checksum: str | None
# The note at the path. Optional so older servers that send only file_path and
# db_checksum still validate; None when no note owns the path any more.
title: str | None = None
permalink: str | None = None


type WriteNoteResponse = Annotated[
Expand Down
7 changes: 6 additions & 1 deletion src/basic_memory/services/entity_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,12 @@ def _apply_markdown_entity_fields(
entity.permalink = markdown.frontmatter.permalink
entity.file_path = file_path.as_posix()
entity.content_type = "text/markdown"
entity.created_at = markdown.created
# Trigger: an existing row is re-read from Markdown (edit, move, reindex).
# Why: without a declared `created`, markdown.created is the file's ctime, which
# every atomic rewrite or rename moves (#1716).
# Outcome: only the author's frontmatter `created` changes the creation time.
if markdown.frontmatter.metadata.get("created") is not None:
entity.created_at = markdown.created
entity.updated_at = markdown.modified

normalized_metadata = normalize_frontmatter_metadata(markdown.frontmatter.metadata or {})
Expand Down
22 changes: 21 additions & 1 deletion src/basic_memory/services/note_content_writes.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,27 @@ async def write_note(
"message": str() as message,
"db_checksum": (str() | None) as current,
} if expected_checksum is not None and message == STALE_BASE_CHECKSUM_MESSAGE:
return RevisionConflict(data.file_path, current_db_checksum=current)
# The pre-lock lookup can predate the winning write, which may have
# retitled the note, so name it as it reads now (#1719). A note
# deleted under the update lock has no current revision and no name.
current_note: NoteLocation | None = None
if target is not None and current is not None:
async with self.session_maker() as session:
winner = await entity_repository.get_by_external_id(
session, target.external_id, load_relations=False
)
if winner is not None:
current_note = NoteLocation(
str(winner.external_id),
winner.title,
winner.file_path,
winner.permalink,
)
return RevisionConflict(
data.file_path,
current_db_checksum=current,
note=current_note,
)
case _:
raise

Expand Down
21 changes: 21 additions & 0 deletions src/basic_memory/services/note_preparation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from __future__ import annotations

import re
from copy import deepcopy
from itertools import dropwhile
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
Expand Down Expand Up @@ -602,6 +604,10 @@ def replace_section_content(
return "\n".join([*lines[: section_line_index + 1], new_content, *lines[end_index:]])


# A Markdown list item line: a bullet (-, *, +) or an ordered marker (1. or 1)).
_LIST_ITEM_LINE = re.compile(r"^\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)")


def insert_relative_to_section(
current_content: str,
section_header: str,
Expand Down Expand Up @@ -635,6 +641,21 @@ def insert_relative_to_section(
insert_lines = ["", *insert_lines]
return "\n".join([*before, *insert_lines, "", *lines[index:]])
after = lines[index + 1 :]
spacer = len(after) - len(list(dropwhile(lambda line: not line.strip(), after)))
first_content = after[spacer] if spacer < len(after) else None
# Trigger: a list item is inserted and the section opens with a list, either
# right under the heading or after the conventional blank line.
# Why: a blank line between the inserted item and the list splits it in two
# (#1720).
# Outcome: the item joins the list tightly, below any heading spacer.
if (
first_content is not None
and _LIST_ITEM_LINE.match(insert_lines[-1])
and _LIST_ITEM_LINE.match(first_content)
):
return "\n".join([*lines[: index + 1], *after[:spacer], *insert_lines, *after[spacer:]])
# Anything else keeps a blank line, so an inserted paragraph never runs into
# the section's first paragraph.
if after and after[0].strip():
insert_lines.append("")
return "\n".join([*lines[: index + 1], *insert_lines, *after])
Expand Down
3 changes: 2 additions & 1 deletion src/basic_memory/services/note_write_outcomes.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,12 @@ class Locked:
class RevisionConflict:
"""The note at the path is no longer the revision the caller expected to replace.
``current_db_checksum`` is None when no note owns the path any more.
``current_db_checksum`` and ``note`` are None when no note owns the path any more.
"""

file_path: str
current_db_checksum: str | None
note: NoteLocation | None = None


@dataclass(frozen=True, slots=True)
Expand Down
Loading
Loading