Environment: basic-memory 0.22.1, macOS (APFS), Python 3.14, project synced with Linux peers via Syncthing.
What happens
On macOS the same filename can exist on disk in either NFC or NFD form — APFS is normalization-preserving, so whichever form was used at write time is what is stored. When a project is synced across platforms, the form flips: Syncthing enforces NFD on macOS and NFC everywhere else. That is documented, intended behaviour, and a request to make the normalization user-selectable was declined in syncthing/syncthing#5339 — "This seems super duper niche and honestly I don't think it's something we want to dive into". So for any cross-platform synced vault, filename form on macOS is outside the user's control.
EntityRepository compares file_path byte-wise:
query = self.select().where(Entity.file_path == Path(file_path).as_posix())
A lookup issued in one normalization form does not match a row stored in the other. The sync layer then treats an already-indexed file as new and creates a second entity for the same file, whose permalink gets a -1 suffix from the collision counter in entity_service.py.
Symptoms
read_note by title returns two candidates for one file; search results contain duplicates.
edit_note on an affected note fails with sqlite3.IntegrityError: UNIQUE constraint failed: entity.permalink, entity.project_id — the update tries to claim a permalink already owned by the twin row. The note becomes uneditable via MCP.
- Permalinks accumulate
-1, -1-1 suffixes permanently, which breaks any external tracking keyed by permalink.
- The duplicates are invisible to a plain
GROUP BY file_path — the strings differ byte-wise. They only show up when grouping by unicodedata.normalize('NFC', file_path).
In one vault of 832 notes this produced 34 duplicate pairs over two weeks. reindex --full --search collapses them (it rebuilds entity rows from the filesystem), but they come back as soon as filenames drift again.
Reproduce
write_note with a non-ASCII title, e.g. Проверка нормализации. The file lands as NFC.
- Rename the file to its NFD form. Note: a direct
os.rename(nfd, nfc) is a no-op on APFS — you must go through an intermediate ASCII name.
- Let the watcher sync. A second entity appears with a
-1 permalink.
Note: the codebase already has utils.normalize_file_path_for_comparison(), but it is only used by detect_potential_file_conflicts() — not on the lookup path.
Suggested fix
Match against every normalization form rather than the raw string:
def _file_path_variants(file_path: Union[Path, str]) -> List[str]:
posix = Path(file_path).as_posix()
variants = [posix]
for form in ("NFC", "NFD"):
candidate = unicodedata.normalize(form, posix)
if candidate not in variants:
variants.append(candidate)
return variants
applied in get_by_file_path, the permalink-by-path lookup, get_by_file_paths (used by sync change detection) and delete_by_file_path.
I am running this as a local patch. Verified end-to-end: created a note (NFC filename), renamed the file to NFD, let the watcher sync — the entity count stayed at 1, the permalink kept no suffix, edit_note no longer raised IntegrityError, and delete_note removed the NFD file correctly. Duplicates across the database went to 0.
A stricter alternative is to normalize file_path to NFC on write and migrate existing rows, but that needs a migration; matching both forms is backward-compatible.
Happy to open a PR if the approach looks right.
Environment: basic-memory 0.22.1, macOS (APFS), Python 3.14, project synced with Linux peers via Syncthing.
What happens
On macOS the same filename can exist on disk in either NFC or NFD form — APFS is normalization-preserving, so whichever form was used at write time is what is stored. When a project is synced across platforms, the form flips: Syncthing enforces NFD on macOS and NFC everywhere else. That is documented, intended behaviour, and a request to make the normalization user-selectable was declined in syncthing/syncthing#5339 — "This seems super duper niche and honestly I don't think it's something we want to dive into". So for any cross-platform synced vault, filename form on macOS is outside the user's control.
EntityRepositorycomparesfile_pathbyte-wise:A lookup issued in one normalization form does not match a row stored in the other. The sync layer then treats an already-indexed file as new and creates a second entity for the same file, whose permalink gets a
-1suffix from the collision counter inentity_service.py.Symptoms
read_noteby title returns two candidates for one file; search results contain duplicates.edit_noteon an affected note fails withsqlite3.IntegrityError: UNIQUE constraint failed: entity.permalink, entity.project_id— the update tries to claim a permalink already owned by the twin row. The note becomes uneditable via MCP.-1,-1-1suffixes permanently, which breaks any external tracking keyed by permalink.GROUP BY file_path— the strings differ byte-wise. They only show up when grouping byunicodedata.normalize('NFC', file_path).In one vault of 832 notes this produced 34 duplicate pairs over two weeks.
reindex --full --searchcollapses them (it rebuilds entity rows from the filesystem), but they come back as soon as filenames drift again.Reproduce
write_notewith a non-ASCII title, e.g.Проверка нормализации. The file lands as NFC.os.rename(nfd, nfc)is a no-op on APFS — you must go through an intermediate ASCII name.-1permalink.Note: the codebase already has
utils.normalize_file_path_for_comparison(), but it is only used bydetect_potential_file_conflicts()— not on the lookup path.Suggested fix
Match against every normalization form rather than the raw string:
applied in
get_by_file_path, the permalink-by-path lookup,get_by_file_paths(used by sync change detection) anddelete_by_file_path.I am running this as a local patch. Verified end-to-end: created a note (NFC filename), renamed the file to NFD, let the watcher sync — the entity count stayed at 1, the permalink kept no suffix,
edit_noteno longer raisedIntegrityError, anddelete_noteremoved the NFD file correctly. Duplicates across the database went to 0.A stricter alternative is to normalize
file_pathto NFC on write and migrate existing rows, but that needs a migration; matching both forms is backward-compatible.Happy to open a PR if the approach looks right.