Skip to content

[BUG] edit_note joins content with a single newline — appended --- silently turns the preceding paragraph into a setext H2 #1585

Description

@Hetox

Bug Description

edit_note joins appended/replacement content to the surrounding text with a single \n rather than a blank line. Because Markdown block constructs need a blank line to be separated, this silently corrupts document structure. The worst case is CommonMark's setext heading rule: appending a --- thematic break after a paragraph turns that paragraph into an <h2>.

The corruption is invisible in the tool response (which reports success) and only shows up later — in rendered output, and in Basic Memory's own section parsing, since the phantom heading becomes a real section boundary.

Steps To Reproduce

Against a basic-memory mcp --transport streamable-http server, v0.23.2:

// 1. create a note whose last line is a paragraph
write_note(project="main", title="Setext Repro", folder="probe", content=
  "# Setext Repro\n\n## Section One\nNote: live Kraken prices checked 2026-09-20.")

// 2. append a separator + a new section
edit_note(project="main", identifier="main/probe/setext-repro", operation="append",
  content="---\n\n## Section Two\nsecond body")

Resulting file on disk:

## Section One
Note: live Kraken prices checked 2026-09-20.
---

## Section Two
second body

Parsed with the same markdown-it-py Basic Memory bundles:

heading_open h2  markup='##'  'Section One'
heading_open h2  markup='-'   'Note: live Kraken prices checked 2026-09-20.'   <-- setext H2
heading_open h2  markup='##'  'Section Two'

The --- never becomes a thematic break, and the note now has a heading nobody wrote.

Expected Behavior

append should separate appended block content from existing content with a blank line, so the --- parses as <hr> and the paragraph stays a paragraph.

Actual Behavior

The paragraph is promoted to an <h2> whose text is the paragraph body. edit_note reports operation: Added 4 lines to end of note with no warning.

Same class, other operations

replace_section also drops the blank line before the following heading:

## Observations
- [test] replaced observation #probe
Note: live prices checked 2026-09-21.
## Next Section          <-- blank line before this heading removed

Here ATX syntax saves it, but any ---, setext underline, indented block or list that lands adjacent is subject to the same silent reinterpretation. In practice this bites agents that maintain long notes: a section rewrite eats the --- separators, the agent appends them back, and each re-added --- creates a new phantom <h2>.

Files are also written with no trailing newline, which contributes (\ No newline at end of file on every commit of a Basic Memory-managed note).

Environment

  • OS: Ubuntu 24.04
  • Python: 3.13 (container)
  • Basic Memory: 0.23.2 (ghcr.io/basicmachines-co/basic-memory:latest)
  • Installation: Docker, streamable-http transport
  • Client: agent over MCP (TypingMind / Open WebUI-style host), also reproduced with a raw JSON-RPC client

Possible Solution

src/basic_memory/services/note_preparation.py, apply_edit_operation:

if operation == "append":
    return (
        current_content
        + ("\n" if current_content and not current_content.endswith("\n") else "")
        + content
    )

This guarantees at most one newline. Appending block-level Markdown needs two. Something like:

if operation == "append":
    if not current_content:
        return content
    sep = "\n" * max(0, 2 - (len(current_content) - len(current_content.rstrip("\n"))))
    return current_content + sep + content

with the equivalent blank-line guarantee applied in replace_section_content and insert_relative_to_section (both around the boundary they splice at), plus a trailing newline when writing the file.

A cheap defence-in-depth check would be to re-parse the edited content and reject/repair an edit that creates a heading the caller did not write — that is exactly the signature of this bug.

Happy to open a PR if the approach looks right.

Activity

  1. FBISiri commented on Sep 22, 2026

    @FBISiri
    Contributor

    The same single-newline join exists in the prepend path, and it fails on a different set of inputs than append does.

    Baseline: main @ 3bf2d523.

    • apply_edit_operation, src/basic_memory/services/note_preparation.py:655-660 — append branch.
    • _prepend_after_frontmatter, same file, 635-643 — both branches (line 639 for notes with frontmatter, line 643 without) use the identical ("\n" if content and not content.endswith("\n") else "") guard.

    I copied both join functions verbatim into a standalone script (no basic-memory install needed) and rendered the output with markdown-it-py 2.0.1 in CommonMark mode. "Broken" below means the rendered HTML differs from the same edit performed with a blank-line join.

    Append — base "Some paragraph text", no trailing newline:

    appended joined renders as broken
    --- '…text\n---' <h2>Some paragraph text</h2> yes
    === '…text\n===' <h1>Some paragraph text</h1> yes
    normal text '…text\nnormal text' <p>Some paragraph text normal text</p> yes
    ## Heading '…text\n## Heading' <p>…</p><h2>Heading</h2> no
    - list item '…text\n- list item' <p>…</p><ul><li>…</li></ul> no
    > quote '…text\n> quote' <p>…</p><blockquote>…</blockquote> no

    ATX headings, bullets and blockquotes survive because CommonMark lets those interrupt a paragraph. Setext underlines cannot be interrupted — they consume the line above. Control group: joining with \n\n instead makes all six render as two separate blocks, including --- → <hr />.

    Prepend — this is the part I think is missing from the report. Prepending "Some paragraph text" to a body that starts with certain constructs:

    body starts with result broken
    --- (thematic break) <h2>Some paragraph text</h2> — break is eaten yes
    Heading + --- (setext) <h2>Some paragraph text Heading</h2> — merged yes
    2. second item <p>Some paragraph text 2. second item</p> — list destroyed yes
    # Heading, > quote, - item unchanged no

    The 2. case is lazy continuation, not setext: only 1. may interrupt a paragraph, so any note whose body opens with a renumbered ordered list loses the list entirely. I did not find a test covering newline joining for either operation.

    On the fix: an unconditional \n\n is one line and closes every case above, but it does change output for the plain normal text append, where merging into the preceding paragraph may be what some callers rely on. Context-aware joining keeps that behavior but means parsing the boundary on both sides. Which of those you want probably depends on whether append is documented as "add a block" or "add text" — that is your call, not mine.

  2. added this to the v0.24.0 milestone on Oct 9, 2026
  3. added a commit that references this issue on Oct 9, 2026
    fef8374
  4. added a commit that references this issue on Oct 10, 2026
    2db4193
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions