What happens
edit_note(operation="replace_section") does not replace the section when the header does not match exactly. It appends a second section with the same name to the end of the note, leaves the original and its content untouched, and reports a successful replacement.
The note is then left holding two sections with the same heading — the stale one and the new one — and the caller is told the replacement happened.
Reproduction
main at 3452c821, SQLite, default config. Public MCP tools only.
BODY = """# Status
## Open items #urgent #work
- The first thing, which is out of date.
- The second thing, also out of date.
## Closing
Unrelated trailing content that must survive.
"""
await write_note(title="Status", content=BODY, directory="", project="main")
await edit_note(
identifier="Status",
operation="replace_section",
section="## Open items", # the heading, without its trailing tags
content="- Replaced content.\n",
project="main",
)
Reported:
operation: Replaced content under section '## Open items'
Resulting note:
# Status
## Open items #urgent #work
- The first thing, which is out of date.
- The second thing, also out of date.
## Closing
Unrelated trailing content that must survive.
## Open items
- Replaced content.
Nothing was replaced. The stale content is still there, and there are now two ## Open items sections.
Cause
src/basic_memory/services/note_preparation.py, in the section-replacement path:
matches = [
index
for index, line in enumerate(lines)
if not fenced[index] and line.strip() == section_header.strip()
]
if len(matches) > 1:
raise ValueError(
f"Multiple sections found with header '{section_header}'. "
"Section replacement requires unique headers."
)
if not matches:
logger.info(f"Section '{section_header}' not found, appending to end of document")
separator = "\n\n" if current_content and not current_content.endswith("\n\n") else ""
return current_content + separator + section_header + "\n" + new_content
Two ways of failing to identify one section, two opposite answers. Ambiguity raises. Absence logs at INFO and creates.
The match is exact on the stripped line, so any heading carrying trailing text — tags, a date, a status marker — cannot be addressed by its name alone.
Why this reads as a bug rather than a design choice
Creating a section on a miss is a defensible default for something like append, where "put this somewhere sensible" is a reasonable reading of the request.
replace_section names an existing thing. If it is not there, the operation as asked for cannot be performed, and the two available answers are to refuse or to silently do something else. This does the second and then reports the first.
The sibling operation in the same tool already takes the other view, and its docstring says so:
append_to_section: Add content at the END of the named section... Errors if the section does not exist — it will not create one, and it will not fall back to a file-level append.
So the reasoning is already present in the codebase; replace_section just does not share it.
Why the silence is the expensive part
The failure is invisible at the call site — a success message naming the exact operation that did not occur — and invisible afterwards, because the note still contains a section with the right name. Anything that later reads that note by section name gets whichever of the two the parser reaches first.
For an agent editing a note it cannot see, this is close to undetectable without reading the whole note back and counting headings.
Two ways to fix it, and I would rather ask than assume
A — refuse, like the sibling. if not matches: raise ValueError(...), naming the header it looked for and, if it is cheap, the headers that do exist. Symmetric with the ambiguity branch directly above it, matches append_to_section's documented behavior, and is a two-line change. It is a behavior change for anyone relying on create-on-miss.
B — keep creating, but say so. Return a result that states a section was created rather than replaced. Smaller blast radius, but it leaves an operation named replace_section doing something other than replacing, and callers have to read the response text to find out which happened.
There is a separate question either way: should the header match tolerate trailing content? A heading like ## Open items #urgent #work is ordinary in tag-using vaults, and matching on the heading text alone would let it be addressed. That may be out of scope here — the silent-create is a bug regardless of how the match is defined — but if the match stays exact, the error message from A is what makes it discoverable.
I have not written a patch. Happy to, once you say which behavior you want.
Related
insert_before_section and insert_after_section take the same kind of argument. I have not tested them against a missing section, and it may be the same path.
What happens
edit_note(operation="replace_section")does not replace the section when the header does not match exactly. It appends a second section with the same name to the end of the note, leaves the original and its content untouched, and reports a successful replacement.The note is then left holding two sections with the same heading — the stale one and the new one — and the caller is told the replacement happened.
Reproduction
mainat3452c821, SQLite, default config. Public MCP tools only.Reported:
Resulting note:
Nothing was replaced. The stale content is still there, and there are now two
## Open itemssections.Cause
src/basic_memory/services/note_preparation.py, in the section-replacement path:Two ways of failing to identify one section, two opposite answers. Ambiguity raises. Absence logs at INFO and creates.
The match is exact on the stripped line, so any heading carrying trailing text — tags, a date, a status marker — cannot be addressed by its name alone.
Why this reads as a bug rather than a design choice
Creating a section on a miss is a defensible default for something like
append, where "put this somewhere sensible" is a reasonable reading of the request.replace_sectionnames an existing thing. If it is not there, the operation as asked for cannot be performed, and the two available answers are to refuse or to silently do something else. This does the second and then reports the first.The sibling operation in the same tool already takes the other view, and its docstring says so:
So the reasoning is already present in the codebase;
replace_sectionjust does not share it.Why the silence is the expensive part
The failure is invisible at the call site — a success message naming the exact operation that did not occur — and invisible afterwards, because the note still contains a section with the right name. Anything that later reads that note by section name gets whichever of the two the parser reaches first.
For an agent editing a note it cannot see, this is close to undetectable without reading the whole note back and counting headings.
Two ways to fix it, and I would rather ask than assume
A — refuse, like the sibling.
if not matches: raise ValueError(...), naming the header it looked for and, if it is cheap, the headers that do exist. Symmetric with the ambiguity branch directly above it, matchesappend_to_section's documented behavior, and is a two-line change. It is a behavior change for anyone relying on create-on-miss.B — keep creating, but say so. Return a result that states a section was created rather than replaced. Smaller blast radius, but it leaves an operation named
replace_sectiondoing something other than replacing, and callers have to read the response text to find out which happened.There is a separate question either way: should the header match tolerate trailing content? A heading like
## Open items #urgent #workis ordinary in tag-using vaults, and matching on the heading text alone would let it be addressed. That may be out of scope here — the silent-create is a bug regardless of how the match is defined — but if the match stays exact, the error message from A is what makes it discoverable.I have not written a patch. Happy to, once you say which behavior you want.
Related
insert_before_sectionandinsert_after_sectiontake the same kind of argument. I have not tested them against a missing section, and it may be the same path.