You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[BUG] replace_section silently preserves content past the next h2 when replacement adds new h2 headings #1012
replace_section on a heading replaces from that heading to the next same-or-higher-level heading in the original file. If the replacement content includes new h2 headings that match later section names in the original, those original sections get preserved after the new content — which silently duplicates content.
Repro
A ticket note like this:
## Observations-[status] open
-[priority] high
## Description
Jamie called in...
## Relations- reported_by [[Jamie Rivera]]
## Observations-[status] resolved
-[resolution] ...
## Description <- from replacement
Updated...
## Resolution <- from replacement
Fix details...
## Description <- ORIGINAL, still here
Jamie called in...
## Relations- reported_by [[Jamie Rivera]]
The original ## Description survives because replace_section stopped at the original ## Description heading and resumed after it, but my new content also had a ## Description heading. Result: duplicate sections.
Expected
One of:
Detect h2s in the replacement and refuse / warn ("replacement content contains '## Description' which would conflict with existing section — use overwrite or separate calls").
Replace through all collided headings in the original.
Document this behavior clearly in the replace_section description so callers know to use write_note(overwrite=True) for multi-section rewrites.
Workaround
Use write_note with overwrite=true for any edit that crosses section boundaries.
Discovered while
Building a help-desk demo for basicmemory.com — agents resolving tickets need to rewrite multiple sections at once, and this trap is easy to fall into without realizing.
Add appropriate labels (bug added; other labels not yet created in repo)
Post triage summary
Classification
Field
Value
Type
Bug
Priority
Medium — silent data duplication; workaround exists (write_note with overwrite=true)
Component
MCP — edit_note tool, replace_section operation
Complexity
Medium — root cause is clear; fix requires care not to break subsection-preservation behavior
Duplicates
None found
Root Cause
The bug is in entity_service.py:replace_section_content (lines 1267–1353). The section-skip loop (lines 1338–1345) stops consuming original lines at the first # heading of any level:
# Skip the original section content until next header or endwhilei<len(lines):
next_line=lines[i]
# Stop consuming when we hit any header (preserve subsections)ifnext_line.startswith("#"):
break# <-- stops at ## Description in originali+=1# Continue processing from the next headercontinue
When the replacement content also includes ## Description, the result is:
Replacement content's ## Description is emitted (it was already appended as part of new_content)
The loop stops at the original ## Description (because it starts with #)
The original ## Description and everything after it is then also emitted
This gives two ## Description sections.
Recommended Fix
Option 1 (defensive, safest): detect headings in new_content that collide with headings in the remaining original content, and raise a ValueError with a clear message directing the caller to use write_note instead:
ValueError: replacement content contains '## Description' which already exists after
this section in the document. Use write_note(overwrite=True) for multi-section rewrites.
This preserves the current skip-at-any-heading behavior (which protects subsections), adds no new edge cases, and gives callers an actionable error rather than silent corruption.
Option 2 (smarter boundary): When skipping original content, only skip up to the next heading of same or higher level as the target section (e.g., for ## Foo, skip until the next ## or #). This is standard markdown section semantics but would change existing behavior for docs that rely on sub-heading preservation.
Option 3 (docs-only): Document the limitation clearly in the edit_note tool description. Lower effort but doesn't prevent silent data loss.
My recommendation: Option 1. It's the safest change, prevents silent corruption, and the error message steers users to the correct tool.
Files to Change
src/basic_memory/services/entity_service.py — replace_section_content (lines ~1338–1345): add collision detection before the replacement loop
tests/services/test_entity_service.py — add a test case for the collision scenario
Tool description in src/basic_memory/mcp/tools/edit_note.py — note the single-section scope of replace_section
Adding a second, simpler manifestation of this same root cause — and a correction to the mechanism described above.
The issue body says replace_section "stops at the next same-or-higher-level heading." It actually stops at the next heading of any level — entity_service.py:1336:
ifnext_line.startswith("#"):
break
Minimal repro (no h2 collision in the replacement needed):
## Section A
intro text
### Sub A1
body
### Sub A2
body
## Section B
Expected: Section A and its subsections A1/A2 are replaced, up to ## Section B.
Actual: only the text between ## Section A and ### Sub A1 is replaced; ### Sub A1 / ### Sub A2 are left orphaned after the new content. When the rewritten content reintroduces those subsections, you get silent duplication.
The current behavior is documented as intentional ("preserve subsections"), so this is really a missing capability: there's no way to replace a section including its descendants in one call, and the silent partial-replace is surprising.
Proposed fix (level-aware boundary): parse the target heading's level and only break on a heading of the same or higher level:
Since this reverses a deliberate design choice, an alternative is to keep the current default and add an explicit include_subsections: bool (or depth) parameter — or, at minimum, detect the orphaning and warn. Happy to send a PR for whichever direction you prefer.
Status: confirmed on main. replace_section_content (entity_service.py) stops consuming original content at a heading of any level, so the original next-h2 section (and subsections) are re-emitted after the replacement — that's the silent duplication/orphaning. groksrc's mechanism description is the accurate one; the earlier "same-or-higher" framing was off.
Worth noting this is currently deliberate: the "stop at any header, preserve subsections" behavior is documented in the docstring and locked by test_edit_entity_replace_section_with_subsections, so a fix changes an intentional contract. Plan: keep subsection-protection as the default, but detect the same-heading collision case (re-emitting a heading the replacement already contains is never "preserving a subsection") and handle it explicitly, add an opt-in for full-section rewrites, and state the single-section scope clearly in the edit_note tool docs.
Decision (supersedes the plan in the previous comment): replace_section becomes level-aware by default. Replacing an h2 section consumes everything through the next h2 (or h1) in the original document — including h3+ subsections — matching how Obsidian and most tools scope a section. Replacement content is free to introduce new h2 headings; since the boundary is determined by the original document, the duplication reported here goes away.
Since section semantics are one of those things people will never fully agree on, the old behavior stays reachable: a new parameter on the replace_section operation (working name replace_subsections: bool = True) lets callers opt back into replacing only the immediate content up to the first subheading. Docstring, edit_note tool docs, and the test locking the old behavior get updated together. PR incoming.
Activity
claude commentedon Jun 23, 2026
Claude finished @phernandez's task in 2m 30s —— View job
Triage Analysis
replace_sectioncodebugadded; other labels not yet created in repo)Classification
write_notewithoverwrite=true)edit_notetool,replace_sectionoperationRoot Cause
The bug is in
entity_service.py:replace_section_content(lines 1267–1353). The section-skip loop (lines 1338–1345) stops consuming original lines at the first#heading of any level:When the replacement content also includes
## Description, the result is:## Descriptionis emitted (it was already appended as part ofnew_content)## Description(because it starts with#)## Descriptionand everything after it is then also emittedThis gives two
## Descriptionsections.Recommended Fix
Option 1 (defensive, safest): detect headings in
new_contentthat collide with headings in the remaining original content, and raise aValueErrorwith a clear message directing the caller to usewrite_noteinstead:This preserves the current skip-at-any-heading behavior (which protects subsections), adds no new edge cases, and gives callers an actionable error rather than silent corruption.
Option 2 (smarter boundary): When skipping original content, only skip up to the next heading of same or higher level as the target section (e.g., for
## Foo, skip until the next##or#). This is standard markdown section semantics but would change existing behavior for docs that rely on sub-heading preservation.Option 3 (docs-only): Document the limitation clearly in the
edit_notetool description. Lower effort but doesn't prevent silent data loss.My recommendation: Option 1. It's the safest change, prevents silent corruption, and the error message steers users to the correct tool.
Files to Change
src/basic_memory/services/entity_service.py—replace_section_content(lines ~1338–1345): add collision detection before the replacement looptests/services/test_entity_service.py— add a test case for the collision scenariosrc/basic_memory/mcp/tools/edit_note.py— note the single-section scope ofreplace_sectiongroksrc commentedon Jun 26, 2026
Adding a second, simpler manifestation of this same root cause — and a correction to the mechanism described above.
The issue body says
replace_section"stops at the next same-or-higher-level heading." It actually stops at the next heading of any level —entity_service.py:1336:Minimal repro (no h2 collision in the replacement needed):
Call
edit_note(identifier, "replace_section", section="## Section A", content="<rewritten Section A>").Expected: Section A and its subsections A1/A2 are replaced, up to
## Section B.Actual: only the text between
## Section Aand### Sub A1is replaced;### Sub A1/### Sub A2are left orphaned after the new content. When the rewritten content reintroduces those subsections, you get silent duplication.The current behavior is documented as intentional ("preserve subsections"), so this is really a missing capability: there's no way to replace a section including its descendants in one call, and the silent partial-replace is surprising.
Proposed fix (level-aware boundary): parse the target heading's level and only break on a heading of the same or higher level:
Since this reverses a deliberate design choice, an alternative is to keep the current default and add an explicit
include_subsections: bool(ordepth) parameter — or, at minimum, detect the orphaning and warn. Happy to send a PR for whichever direction you prefer.phernandez commentedon Jul 15, 2026
Status: confirmed on main.
replace_section_content(entity_service.py) stops consuming original content at a heading of any level, so the original next-h2 section (and subsections) are re-emitted after the replacement — that's the silent duplication/orphaning. groksrc's mechanism description is the accurate one; the earlier "same-or-higher" framing was off.Worth noting this is currently deliberate: the "stop at any header, preserve subsections" behavior is documented in the docstring and locked by
test_edit_entity_replace_section_with_subsections, so a fix changes an intentional contract. Plan: keep subsection-protection as the default, but detect the same-heading collision case (re-emitting a heading the replacement already contains is never "preserving a subsection") and handle it explicitly, add an opt-in for full-section rewrites, and state the single-section scope clearly in the edit_note tool docs.phernandez commentedon Jul 15, 2026
Decision (supersedes the plan in the previous comment):
replace_sectionbecomes level-aware by default. Replacing an h2 section consumes everything through the next h2 (or h1) in the original document — including h3+ subsections — matching how Obsidian and most tools scope a section. Replacement content is free to introduce new h2 headings; since the boundary is determined by the original document, the duplication reported here goes away.Since section semantics are one of those things people will never fully agree on, the old behavior stays reachable: a new parameter on the replace_section operation (working name
replace_subsections: bool = True) lets callers opt back into replacing only the immediate content up to the first subheading. Docstring, edit_note tool docs, and the test locking the old behavior get updated together. PR incoming.fix(core): make replace_section level-aware with replace_subsections …
fix(core): make replace_section level-aware with replace_subsections …