Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

### Bug Fixes

- `write_note` and `move_note` no longer create case-duplicate folders
(`schemas/` beside an existing `Schemas/`). When a requested directory is
not an existing folder but matches exactly one existing folder
case-insensitively, the note lands in the existing folder; unknown folders
are still created as given, and existing case-variant siblings keep today's
exact behavior. Folders are resolved from the indexed database paths, so
local and cloud runtimes behave identically. (#1326)

## v0.23.1 (2026-08-25)

Fast-follow patch to v0.23.0 focused on PostgreSQL search parity and a
Expand Down
106 changes: 99 additions & 7 deletions src/basic_memory/indexing/accepted_note_mutation_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
)
from basic_memory.schemas.base import Entity as EntitySchema
from basic_memory.schemas.request import EditEntityRequest
from basic_memory.utils import resolve_directory_casing

type AcceptedNoteMutationChange = RuntimeAcceptedNoteChange[RuntimeNoteContentResponsePayload]
type AcceptedNoteMutationUserProfileId = UUID
Expand Down Expand Up @@ -233,6 +234,11 @@ async def get_by_file_path(
load_relations: bool = False,
) -> Entity | None: ...

async def get_distinct_directories(
self,
session: AsyncSession,
) -> list[str]: ...


class AcceptedNoteMutationNoteContentRepository(Protocol):
"""note_content lookup capability for accepted-note mutations."""
Expand Down Expand Up @@ -379,6 +385,58 @@ async def resolve_accepted_note_source_checksum(
)


async def resolve_accepted_note_directory(
session: AsyncSession,
*,
project_id: ProjectId,
directory: str,
dependencies: AcceptedNoteMutationDependencies,
) -> str:
"""Resolve a requested note directory against existing folder casing (#1326).
Trigger: the requested directory is not an existing folder but matches
exactly one existing folder case-insensitively.
Why: LLM callers guess plausible casing ("schemas" beside an existing
"Schemas/"); on case-sensitive storage (cloud object storage) the guess
silently creates a case-duplicate sibling folder. Folders are derived
from indexed entity file paths in the DB, never by probing storage, so
local and cloud runtimes resolve identically.
Outcome: a unique case-insensitive match adopts the existing folder's
casing; exact matches, unknown folders, and ambiguous case-variant
siblings keep the requested casing unchanged.
"""
if not directory:
return directory
entity_repository = dependencies.lookup_repositories.entity_repository(project_id)
existing_directories = await entity_repository.get_distinct_directories(session)
Comment thread
phernandez marked this conversation as resolved.
Comment thread
phernandez marked this conversation as resolved.
return resolve_directory_casing(directory, existing_directories)


async def resolve_accepted_note_schema_directory(
session: AsyncSession,
*,
project_id: ProjectId,
data: EntitySchema,
dependencies: AcceptedNoteMutationDependencies,
) -> EntitySchema:
"""Return the write schema with its directory resolved to existing casing.
The schema's ``file_path`` is computed from ``directory``, so adjusting the
directory redirects the conflict lookup, preparation, and permalink
resolution downstream. The caller's schema is never mutated: a resolved
directory yields a copy so route-owned request data stays as received.
"""
resolved_directory = await resolve_accepted_note_directory(
session,
project_id=project_id,
directory=data.directory,
dependencies=dependencies,
)
if resolved_directory == data.directory:
return data
return data.model_copy(update={"directory": resolved_directory})


async def run_accepted_note_create(
session: AsyncSession,
*,
Expand Down Expand Up @@ -501,10 +559,16 @@ async def _run_accepted_note_create(
dependencies=dependencies,
)

data = await resolve_accepted_note_schema_directory(
session,
project_id=project.id,
data=request.data,
dependencies=dependencies,
)
entity_repository = dependencies.lookup_repositories.entity_repository(project.id)
conflicting_entity = await entity_repository.get_by_file_path(
session,
request.data.file_path,
data.file_path,
load_relations=False,
)
reject_accepted_note_file_path_conflict(
Expand All @@ -515,7 +579,7 @@ async def _run_accepted_note_create(
preparer = dependencies.preparer_factory.create_note_preparer(project)
prepared_write = await prepare_create_or_reject(
preparer,
request.data,
data,
check_storage_exists=dependencies.verify_storage_absent_on_create,
session=session,
)
Expand Down Expand Up @@ -579,10 +643,26 @@ async def _run_accepted_note_update(
existing_file_path = entity.file_path if entity is not None else None
vacated_source: tuple[RuntimeFilePath, RuntimeFileChecksum | None] | None = None

# Trigger: the addressed entity already lives in the exact requested directory.
# Why: casing resolution scans every distinct entity file_path in the project,
# and content-only PUTs (e.g. repeated collaboration-relay saves of the
# same note) are the hot path where the directory cannot change; an exact
# match would resolve to itself anyway because exact match always wins.
# Outcome: only case-variant or relocating PUTs pay for the directory scan.
current_directory = existing_file_path.rpartition("/")[0] if existing_file_path else None
if current_directory == request.data.directory:
data = request.data
else:
data = await resolve_accepted_note_schema_directory(
session,
project_id=project.id,
data=request.data,
dependencies=dependencies,
)
await reject_conflicting_accepted_note_file_path(
session,
project_id=project.id,
file_path=request.data.file_path,
file_path=data.file_path,
allowed_entity_external_id=request.entity_external_id,
dependencies=dependencies,
)
Expand All @@ -600,7 +680,7 @@ async def _run_accepted_note_update(
reject_stale_base_checksum(current_db_checksum=None)
prepared_write = await prepare_create_or_reject(
preparer,
request.data,
data,
check_storage_exists=dependencies.verify_storage_absent_on_create,
session=session,
)
Expand Down Expand Up @@ -630,7 +710,7 @@ async def _run_accepted_note_update(
try:
await preparer.verify_move_destination_absent(
source_file_path=entity.file_path,
destination_file_path=request.data.file_path,
destination_file_path=data.file_path,
)
except EntityAlreadyExistsError as error:
reject_accepted_note_mutation(AcceptedNoteMutationRejectKind.conflict, str(error))
Expand All @@ -644,7 +724,7 @@ async def _run_accepted_note_update(
# A PUT replacement may also rename the note. Capture the exact source bytes before
# persistence mutates the entity and note_content to the destination version so delayed
# cleanup cannot let a later project index recreate the old path as a ghost.
if request.data.file_path != entity.file_path:
if data.file_path != entity.file_path:
vacated_source = (
entity.file_path,
await resolve_accepted_note_source_checksum(
Expand Down Expand Up @@ -693,7 +773,7 @@ async def _run_accepted_note_update(
preparer,
session,
entity=entity,
data=request.data,
data=data,
current_note_content=current_note_content,
user_profile_value=user_profile_value,
)
Expand Down Expand Up @@ -825,6 +905,18 @@ async def _run_accepted_note_move(
dependencies=dependencies,
)
existing_file_path = entity.file_path
# The destination filename keeps its requested casing; only the parent
# directory resolves against existing folders (issue #1326). The path is
# posix-normalized above, so rpartition splits directory from filename.
destination_directory, _, destination_filename = accepted_file_path.rpartition("/")
resolved_directory = await resolve_accepted_note_directory(
session,
project_id=project.id,
directory=destination_directory,
dependencies=dependencies,
)
if resolved_directory != destination_directory:
accepted_file_path = f"{resolved_directory}/{destination_filename}"
# Same-path moves fail fast everywhere by decision (2026-07-14): cloud's
# pre-unification route returned a 200 no-op, local rejected — the unified
# runner keeps the rejection so a mistaken identity move surfaces instead
Expand Down
13 changes: 12 additions & 1 deletion src/basic_memory/mcp/tools/move_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,9 +947,20 @@ async def _ensure_resolved_entity_id() -> str:
# the up-front detection: any divergence the caller did not ask for
# surfaces as a failure rather than a fake success.
# Outcome: report failure with the actual landing path instead of "✅".
# A case-only difference in the PARENT directories is the server resolving
# the destination folder to an existing folder's casing (#1326), not a
# boundary degradation — the success message below reports the actual
# landing path either way. The server always preserves the requested
# filename verbatim, so a basename divergence (even case-only) still
# reports the honest failure.
normalized_requested = PureWindowsPath(destination_path).as_posix().strip("/")
normalized_actual = PureWindowsPath(result.file_path).as_posix().strip("/")
if normalized_actual != normalized_requested:
requested_parent, _, requested_name = normalized_requested.rpartition("/")
actual_parent, _, actual_name = normalized_actual.rpartition("/")
diverged = normalized_actual != normalized_requested and not (
actual_name == requested_name and actual_parent.lower() == requested_parent.lower()
)
if diverged:
logger.warning(
f"Move outcome diverged from intent: requested={destination_path} "
f"actual={result.file_path}"
Expand Down
46 changes: 46 additions & 0 deletions src/basic_memory/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,52 @@ def detect_potential_file_conflicts(file_path: str, existing_paths: List[str]) -
return conflicts


def resolve_directory_casing(directory: str, existing_directories: List[str]) -> str:
"""Resolve a requested directory to the casing of existing folders (#1326).

LLM callers guess plausible-but-wrong casing ("schemas" beside an existing
"Schemas/"), and case-sensitive storage backends then grow case-duplicate
sibling folders. Each path segment resolves against the folders that exist
under the already-resolved parent:

- Exact match wins: the requested segment casing is kept unchanged.
- Unique case-insensitive match: the existing folder's casing is adopted.
- No match: the segment is kept as given (a new folder).
- Multiple case-variant folders exist: ambiguous, the segment is kept as
given (today's exact behavior).

Args:
directory: Requested directory path relative to the project root
("" means the root itself).
existing_directories: Known project folders derived from indexed entity
file paths (see EntityRepository.get_distinct_directories).

Returns:
The directory path with each segment resolved to existing casing.
"""
if not directory:
return directory

existing = set(existing_directories)
resolved_prefix = ""
for segment in directory.split("/"):
candidate = f"{resolved_prefix}/{segment}" if resolved_prefix else segment
if candidate in existing:
resolved_prefix = candidate
continue
# Candidates are constrained to children of the already-resolved parent:
# once a parent segment stays ambiguous (kept as requested), a deeper
# existing folder under a *different* parent casing must not be spliced in.
matches = [
existing_directory
for existing_directory in existing
if existing_directory.lower() == candidate.lower()
and existing_directory.rpartition("/")[0] == resolved_prefix
]
resolved_prefix = matches[0] if len(matches) == 1 else candidate
return resolved_prefix


def valid_project_path_value(path: str):
"""Ensure project path is valid."""
# Allow empty strings as they resolve to the project root
Expand Down
Loading
Loading