Skip to content

[BUG] Emacs autosave #note.md# is indexed as a non-markdown resource and can destroy the real note's entity row #1537

Description

@tsobczynski

Bug Description

An Emacs autosave file (#note.md#, written by Emacs into the same directory as the file being edited) is indexed as a non-markdown resource instead of being ignored. In our case the resulting plain-file row ended up attached to the real note's file_path, replacing that note's entity row. Final state — a single row, where a healthy markdown note row used to be:

column value
title #note.md#
file_path notes/note.md
permalink NULL
note_type file
content_type text/plain
note_content no row
observation / relation 0 / 0

The note's Markdown on disk was never touched — this is purely index corruption.

Two further defects fell out of this and are filed separately:

  • "Checksum-keyed reconciliation can never repair an entity row whose type is wrong" — why no rebuild fixes it
  • ".bmignore cannot express any pattern beginning with #" — why it can't simply be ignored

Root cause (the classification half — proven)

runtime_file_path_is_markdown_note() in basic_memory/runtime/storage.py classifies by suffix:

return PurePosixPath(relative_path).suffix.lower() in RUNTIME_MARKDOWN_FILE_SUFFIXES

PurePosixPath("#note.md#").suffix is ".md#", not ".md". Independently, mimetypes.guess_type("#note.md#") returns (None, None), so FileService.content_type() falls back to "text/plain".

The autosave is therefore routed to _upsert_regular_file() in indexing/batch_indexer.py, which inserts note_type="file", title=Path(file.path).name, permalink=NULL.

This affects any path of the form foo.md<anything>, not only Emacs autosaves.

Steps To Reproduce

  1. basic-memory 0.23.2, local SQLite, watch/index running.
  2. In a project, open any note.md in Emacs and let it autosave — or simply printf 'x' > 'dir/#note.md#'.
  3. Query the index:
select id,title,note_type,content_type,permalink,file_path
from entity where file_path like '%note%';

Expected Behavior

A path that is not exactly *.md / *.markdown should not be ingested as a resource in a notes project when it is plainly editor scratch. Failing that, indexing a resource must never be able to take over an existing markdown note's file_path.

Actual Behavior

A resource row appears with note_type='file', content_type='text/plain', permalink=NULL, title='#note.md#'. In our case it also displaced the real note's row (see below).

Downstream symptoms, in order of nastiness:

  • edit_note with the note's permalink stops resolving. Because append is an upsert, the first append then silently creates a duplicate note at a different path and reports file_created=true.
  • edit_note by the corrupt title fails with Only markdown note mutations are supported by the note-content path — the #...# name is not a valid mutation target.
  • read_note still returns the full content, so the note looks healthy from the MCP side.
  • Not self-healing, and not repairable by re-syncing (companion issue).

How the row reached the real note's file_path — not proven

I could not demonstrate the final write, and want to be explicit about that. Every entity lookup on this path is an exact file_path == match, and the event watcher has no move/rename handling (Move detection: found [1-9] appears nowhere in our logs, and no delete was ever logged for the autosave).

The one remaining candidate is the IntegrityError recovery branch in _upsert_regular_file(): on a file_path conflict it adopts the conflicting row, then calls entity_repository.update() with _file_bookkeeping_updates(), which writes both file_path and content_type. We had three MCP servers watching the same SQLite database concurrently (two from Claude Desktop, one from Claude Code — every index event appears two or three times in the log, and watch-status.json holds a single pid, so the watchers are unaware of each other). That would let the branch adopt the wrong row under a race.

This is consistent with everything observed, including why the autosave's eventual deletion was never logged — the delete runner would have found nothing at notes/#note.md# and returned missing_entity, leaving the ghost. But it is inference, not a demonstration.

Possible Solution

  1. Treat foo.md<suffix> as non-indexable rather than as a resource, and/or ignore #*# by default.
  2. In the _upsert_regular_file() conflict branch, refuse to adopt a row whose content_type is text/markdown or whose permalink is non-NULL, and fail loudly instead of silently rewriting its file_path.
  3. Guard against multiple watchers on one database (advisory lock or PID liveness check) — watch-status.json already assumes a single writer.

Environment

  • OS: macOS 26.6.2 (Darwin 25.6.0), arm64
  • Python: 3.14.7
  • basic-memory: 0.23.2
  • Install method: pipenv virtualenv; MCP servers launched by Claude Desktop and Claude Code
  • Config: local SQLite, semantic_search_enabled: true, index_changes: true, kebab_filenames: false

Activity

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