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
- basic-memory 0.23.2, local SQLite, watch/index running.
- In a project, open any
note.md in Emacs and let it autosave — or simply printf 'x' > 'dir/#note.md#'.
- 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
- Treat
foo.md<suffix> as non-indexable rather than as a resource, and/or ignore #*# by default.
- 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.
- 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
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'sfile_path, replacing that note's entity row. Final state — a single row, where a healthy markdown note row used to be:title#note.md#file_pathnotes/note.mdpermalinknote_typefilecontent_typetext/plainnote_contentobservation/relationThe note's Markdown on disk was never touched — this is purely index corruption.
Two further defects fell out of this and are filed separately:
.bmignorecannot express any pattern beginning with#" — why it can't simply be ignoredRoot cause (the classification half — proven)
runtime_file_path_is_markdown_note()inbasic_memory/runtime/storage.pyclassifies by suffix:PurePosixPath("#note.md#").suffixis".md#", not".md". Independently,mimetypes.guess_type("#note.md#")returns(None, None), soFileService.content_type()falls back to"text/plain".The autosave is therefore routed to
_upsert_regular_file()inindexing/batch_indexer.py, which insertsnote_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
note.mdin Emacs and let it autosave — or simplyprintf 'x' > 'dir/#note.md#'.Expected Behavior
A path that is not exactly
*.md/*.markdownshould 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'sfile_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_notewith the note's permalink stops resolving. Becauseappendis an upsert, the first append then silently creates a duplicate note at a different path and reportsfile_created=true.edit_noteby the corrupt title fails withOnly markdown note mutations are supported by the note-content path— the#...#name is not a valid mutation target.read_notestill returns the full content, so the note looks healthy from the MCP side.How the row reached the real note's
file_path— not provenI 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
IntegrityErrorrecovery branch in_upsert_regular_file(): on afile_pathconflict it adopts the conflicting row, then callsentity_repository.update()with_file_bookkeeping_updates(), which writes bothfile_pathandcontent_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, andwatch-status.jsonholds a singlepid, 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 returnedmissing_entity, leaving the ghost. But it is inference, not a demonstration.Possible Solution
foo.md<suffix>as non-indexable rather than as a resource, and/or ignore#*#by default._upsert_regular_file()conflict branch, refuse to adopt a row whosecontent_typeistext/markdownor whosepermalinkis non-NULL, and fail loudly instead of silently rewriting itsfile_path.watch-status.jsonalready assumes a single writer.Environment
semantic_search_enabled: true,index_changes: true,kebab_filenames: false