basic-memory 0.23.1.dev27__py3-none-any.whl → 0.23.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
basic_memory/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
2
2
 
3
3
  # Package version - updated by release automation
4
- __version__ = "0.23.0"
4
+ __version__ = "0.23.2"
5
5
 
6
6
  # API version for FastAPI - independent of package version
7
7
  __api_version__ = "v2"
@@ -60,6 +60,7 @@ from basic_memory.runtime.storage import (
60
60
  )
61
61
  from basic_memory.schemas.base import Entity as EntitySchema
62
62
  from basic_memory.schemas.request import EditEntityRequest
63
+ from basic_memory.utils import resolve_directory_casing
63
64
 
64
65
  type AcceptedNoteMutationChange = RuntimeAcceptedNoteChange[RuntimeNoteContentResponsePayload]
65
66
  type AcceptedNoteMutationUserProfileId = UUID
@@ -233,6 +234,11 @@ class AcceptedNoteMutationEntityRepository(Protocol):
233
234
  load_relations: bool = False,
234
235
  ) -> Entity | None: ...
235
236
 
237
+ async def get_distinct_directories(
238
+ self,
239
+ session: AsyncSession,
240
+ ) -> list[str]: ...
241
+
236
242
 
237
243
  class AcceptedNoteMutationNoteContentRepository(Protocol):
238
244
  """note_content lookup capability for accepted-note mutations."""
@@ -379,6 +385,58 @@ async def resolve_accepted_note_source_checksum(
379
385
  )
380
386
 
381
387
 
388
+ async def resolve_accepted_note_directory(
389
+ session: AsyncSession,
390
+ *,
391
+ project_id: ProjectId,
392
+ directory: str,
393
+ dependencies: AcceptedNoteMutationDependencies,
394
+ ) -> str:
395
+ """Resolve a requested note directory against existing folder casing (#1326).
396
+
397
+ Trigger: the requested directory is not an existing folder but matches
398
+ exactly one existing folder case-insensitively.
399
+ Why: LLM callers guess plausible casing ("schemas" beside an existing
400
+ "Schemas/"); on case-sensitive storage (cloud object storage) the guess
401
+ silently creates a case-duplicate sibling folder. Folders are derived
402
+ from indexed entity file paths in the DB, never by probing storage, so
403
+ local and cloud runtimes resolve identically.
404
+ Outcome: a unique case-insensitive match adopts the existing folder's
405
+ casing; exact matches, unknown folders, and ambiguous case-variant
406
+ siblings keep the requested casing unchanged.
407
+ """
408
+ if not directory:
409
+ return directory
410
+ entity_repository = dependencies.lookup_repositories.entity_repository(project_id)
411
+ existing_directories = await entity_repository.get_distinct_directories(session)
412
+ return resolve_directory_casing(directory, existing_directories)
413
+
414
+
415
+ async def resolve_accepted_note_schema_directory(
416
+ session: AsyncSession,
417
+ *,
418
+ project_id: ProjectId,
419
+ data: EntitySchema,
420
+ dependencies: AcceptedNoteMutationDependencies,
421
+ ) -> EntitySchema:
422
+ """Return the write schema with its directory resolved to existing casing.
423
+
424
+ The schema's ``file_path`` is computed from ``directory``, so adjusting the
425
+ directory redirects the conflict lookup, preparation, and permalink
426
+ resolution downstream. The caller's schema is never mutated: a resolved
427
+ directory yields a copy so route-owned request data stays as received.
428
+ """
429
+ resolved_directory = await resolve_accepted_note_directory(
430
+ session,
431
+ project_id=project_id,
432
+ directory=data.directory,
433
+ dependencies=dependencies,
434
+ )
435
+ if resolved_directory == data.directory:
436
+ return data
437
+ return data.model_copy(update={"directory": resolved_directory})
438
+
439
+
382
440
  async def run_accepted_note_create(
383
441
  session: AsyncSession,
384
442
  *,
@@ -501,10 +559,16 @@ async def _run_accepted_note_create(
501
559
  dependencies=dependencies,
502
560
  )
503
561
 
562
+ data = await resolve_accepted_note_schema_directory(
563
+ session,
564
+ project_id=project.id,
565
+ data=request.data,
566
+ dependencies=dependencies,
567
+ )
504
568
  entity_repository = dependencies.lookup_repositories.entity_repository(project.id)
505
569
  conflicting_entity = await entity_repository.get_by_file_path(
506
570
  session,
507
- request.data.file_path,
571
+ data.file_path,
508
572
  load_relations=False,
509
573
  )
510
574
  reject_accepted_note_file_path_conflict(
@@ -515,7 +579,7 @@ async def _run_accepted_note_create(
515
579
  preparer = dependencies.preparer_factory.create_note_preparer(project)
516
580
  prepared_write = await prepare_create_or_reject(
517
581
  preparer,
518
- request.data,
582
+ data,
519
583
  check_storage_exists=dependencies.verify_storage_absent_on_create,
520
584
  session=session,
521
585
  )
@@ -579,10 +643,26 @@ async def _run_accepted_note_update(
579
643
  existing_file_path = entity.file_path if entity is not None else None
580
644
  vacated_source: tuple[RuntimeFilePath, RuntimeFileChecksum | None] | None = None
581
645
 
646
+ # Trigger: the addressed entity already lives in the exact requested directory.
647
+ # Why: casing resolution scans every distinct entity file_path in the project,
648
+ # and content-only PUTs (e.g. repeated collaboration-relay saves of the
649
+ # same note) are the hot path where the directory cannot change; an exact
650
+ # match would resolve to itself anyway because exact match always wins.
651
+ # Outcome: only case-variant or relocating PUTs pay for the directory scan.
652
+ current_directory = existing_file_path.rpartition("/")[0] if existing_file_path else None
653
+ if current_directory == request.data.directory:
654
+ data = request.data
655
+ else:
656
+ data = await resolve_accepted_note_schema_directory(
657
+ session,
658
+ project_id=project.id,
659
+ data=request.data,
660
+ dependencies=dependencies,
661
+ )
582
662
  await reject_conflicting_accepted_note_file_path(
583
663
  session,
584
664
  project_id=project.id,
585
- file_path=request.data.file_path,
665
+ file_path=data.file_path,
586
666
  allowed_entity_external_id=request.entity_external_id,
587
667
  dependencies=dependencies,
588
668
  )
@@ -600,7 +680,7 @@ async def _run_accepted_note_update(
600
680
  reject_stale_base_checksum(current_db_checksum=None)
601
681
  prepared_write = await prepare_create_or_reject(
602
682
  preparer,
603
- request.data,
683
+ data,
604
684
  check_storage_exists=dependencies.verify_storage_absent_on_create,
605
685
  session=session,
606
686
  )
@@ -630,7 +710,7 @@ async def _run_accepted_note_update(
630
710
  try:
631
711
  await preparer.verify_move_destination_absent(
632
712
  source_file_path=entity.file_path,
633
- destination_file_path=request.data.file_path,
713
+ destination_file_path=data.file_path,
634
714
  )
635
715
  except EntityAlreadyExistsError as error:
636
716
  reject_accepted_note_mutation(AcceptedNoteMutationRejectKind.conflict, str(error))
@@ -644,7 +724,7 @@ async def _run_accepted_note_update(
644
724
  # A PUT replacement may also rename the note. Capture the exact source bytes before
645
725
  # persistence mutates the entity and note_content to the destination version so delayed
646
726
  # cleanup cannot let a later project index recreate the old path as a ghost.
647
- if request.data.file_path != entity.file_path:
727
+ if data.file_path != entity.file_path:
648
728
  vacated_source = (
649
729
  entity.file_path,
650
730
  await resolve_accepted_note_source_checksum(
@@ -693,7 +773,7 @@ async def _run_accepted_note_update(
693
773
  preparer,
694
774
  session,
695
775
  entity=entity,
696
- data=request.data,
776
+ data=data,
697
777
  current_note_content=current_note_content,
698
778
  user_profile_value=user_profile_value,
699
779
  )
@@ -825,6 +905,18 @@ async def _run_accepted_note_move(
825
905
  dependencies=dependencies,
826
906
  )
827
907
  existing_file_path = entity.file_path
908
+ # The destination filename keeps its requested casing; only the parent
909
+ # directory resolves against existing folders (issue #1326). The path is
910
+ # posix-normalized above, so rpartition splits directory from filename.
911
+ destination_directory, _, destination_filename = accepted_file_path.rpartition("/")
912
+ resolved_directory = await resolve_accepted_note_directory(
913
+ session,
914
+ project_id=project.id,
915
+ directory=destination_directory,
916
+ dependencies=dependencies,
917
+ )
918
+ if resolved_directory != destination_directory:
919
+ accepted_file_path = f"{resolved_directory}/{destination_filename}"
828
920
  # Same-path moves fail fast everywhere by decision (2026-07-14): cloud's
829
921
  # pre-unification route returned a 200 no-op, local rejected — the unified
830
922
  # runner keeps the rejection so a mistaken identity move surfaces instead
@@ -947,9 +947,20 @@ move_note("{identifier}", destination_folder="notes")
947
947
  # the up-front detection: any divergence the caller did not ask for
948
948
  # surfaces as a failure rather than a fake success.
949
949
  # Outcome: report failure with the actual landing path instead of "✅".
950
+ # A case-only difference in the PARENT directories is the server resolving
951
+ # the destination folder to an existing folder's casing (#1326), not a
952
+ # boundary degradation — the success message below reports the actual
953
+ # landing path either way. The server always preserves the requested
954
+ # filename verbatim, so a basename divergence (even case-only) still
955
+ # reports the honest failure.
950
956
  normalized_requested = PureWindowsPath(destination_path).as_posix().strip("/")
951
957
  normalized_actual = PureWindowsPath(result.file_path).as_posix().strip("/")
952
- if normalized_actual != normalized_requested:
958
+ requested_parent, _, requested_name = normalized_requested.rpartition("/")
959
+ actual_parent, _, actual_name = normalized_actual.rpartition("/")
960
+ diverged = normalized_actual != normalized_requested and not (
961
+ actual_name == requested_name and actual_parent.lower() == requested_parent.lower()
962
+ )
963
+ if diverged:
953
964
  logger.warning(
954
965
  f"Move outcome diverged from intent: requested={destination_path} "
955
966
  f"actual={result.file_path}"
basic_memory/utils.py CHANGED
@@ -776,6 +776,52 @@ def detect_potential_file_conflicts(file_path: str, existing_paths: List[str]) -
776
776
  return conflicts
777
777
 
778
778
 
779
+ def resolve_directory_casing(directory: str, existing_directories: List[str]) -> str:
780
+ """Resolve a requested directory to the casing of existing folders (#1326).
781
+
782
+ LLM callers guess plausible-but-wrong casing ("schemas" beside an existing
783
+ "Schemas/"), and case-sensitive storage backends then grow case-duplicate
784
+ sibling folders. Each path segment resolves against the folders that exist
785
+ under the already-resolved parent:
786
+
787
+ - Exact match wins: the requested segment casing is kept unchanged.
788
+ - Unique case-insensitive match: the existing folder's casing is adopted.
789
+ - No match: the segment is kept as given (a new folder).
790
+ - Multiple case-variant folders exist: ambiguous, the segment is kept as
791
+ given (today's exact behavior).
792
+
793
+ Args:
794
+ directory: Requested directory path relative to the project root
795
+ ("" means the root itself).
796
+ existing_directories: Known project folders derived from indexed entity
797
+ file paths (see EntityRepository.get_distinct_directories).
798
+
799
+ Returns:
800
+ The directory path with each segment resolved to existing casing.
801
+ """
802
+ if not directory:
803
+ return directory
804
+
805
+ existing = set(existing_directories)
806
+ resolved_prefix = ""
807
+ for segment in directory.split("/"):
808
+ candidate = f"{resolved_prefix}/{segment}" if resolved_prefix else segment
809
+ if candidate in existing:
810
+ resolved_prefix = candidate
811
+ continue
812
+ # Candidates are constrained to children of the already-resolved parent:
813
+ # once a parent segment stays ambiguous (kept as requested), a deeper
814
+ # existing folder under a *different* parent casing must not be spliced in.
815
+ matches = [
816
+ existing_directory
817
+ for existing_directory in existing
818
+ if existing_directory.lower() == candidate.lower()
819
+ and existing_directory.rpartition("/")[0] == resolved_prefix
820
+ ]
821
+ resolved_prefix = matches[0] if len(matches) == 1 else candidate
822
+ return resolved_prefix
823
+
824
+
779
825
  def valid_project_path_value(path: str):
780
826
  """Ensure project path is valid."""
781
827
  # Allow empty strings as they resolve to the project root
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: basic-memory
3
- Version: 0.23.1.dev27
3
+ Version: 0.23.2
4
4
  Summary: Local-first knowledge management combining Zettelkasten with knowledge graphs
5
5
  Project-URL: Homepage, https://github.com/basicmachines-co/basic-memory
6
6
  Project-URL: Repository, https://github.com/basicmachines-co/basic-memory
@@ -1,4 +1,4 @@
1
- basic_memory/__init__.py,sha256=yREnsMqgfnZJBuh1hZFPgZv-ERBpfL361wFDx9sdijA,256
1
+ basic_memory/__init__.py,sha256=dtmjVy3Q4_DYddSDwS9XOq3okErGMfYtVZ-_1s5p2zQ,256
2
2
  basic_memory/config.py,sha256=gPw1piQZJANYCF-TkpEpc9-vD7-lHnSez8HQidGh5qk,12080
3
3
  basic_memory/config_logging.py,sha256=keqWv5xPOP6fjh1LDQmKMDyduHTYAhr6Y1EKiiYVDnc,1857
4
4
  basic_memory/config_migrations.py,sha256=OO5hwzs3x5EEix-jNMeETrJQwMx0l2iHUywXRLMjzFA,3582
@@ -10,7 +10,7 @@ basic_memory/migration_loop.py,sha256=KGhuCLgrgwW21GAGT9cOczbtRlM4mGi7aUBjG1shQ8
10
10
  basic_memory/project_resolver.py,sha256=vHWHw1X9_1IqtX9Dy5yNXqNRas3wcrpQJVKxJuj4qWg,3801
11
11
  basic_memory/redaction.py,sha256=tYolNeRQdOfcELD9sGtzQbB5dS3odP9PAnzipqmMl6M,4804
12
12
  basic_memory/telemetry.py,sha256=pAkPgbAOmivZDkCAW0S2CKSNw4AqBfMckdsbb2EipcQ,1721
13
- basic_memory/utils.py,sha256=TIv_NtIXJKRyCsGUX_0nWWz65hdiqK_LZ17zq-r0q08,33363
13
+ basic_memory/utils.py,sha256=bXlhv3qHOO_1Lb_2gUspjOZ3H1-qgreWl_n5eR6_Jxg,35426
14
14
  basic_memory/workspace_context.py,sha256=v-u4jO8VFB1WeFZ1UpzZnbf9ph71vvz1OMibpAtrtQI,3648
15
15
  basic_memory/alembic/alembic.ini,sha256=IEZsnF8CbbZnkwBr67LzKKNobHuzTaQNUvM8Psop5xc,3733
16
16
  basic_memory/alembic/env.py,sha256=ZfUWoJeIJKTCsOSvz6mty0c570k2k4MF9cvi7T4sj1g,9581
@@ -160,7 +160,7 @@ basic_memory/index/watch_coordinator.py,sha256=Nif22CFD8romuHTdPTTW1LHyKvy2UyUbv
160
160
  basic_memory/index/watch_service.py,sha256=R4uOFjLVTQjSYo1fE6kOUcnnq3o7JPIlYQFZf0PjXCI,13885
161
161
  basic_memory/indexing/__init__.py,sha256=YdS2QwyyQ7fPxvUr2wkubztu2aolAyuQy9Y0FbH0BH8,236
162
162
  basic_memory/indexing/accepted_note_enqueue_runner.py,sha256=v0BQTdKFOnhwrvls4QBqF2fIMejRkeFWyQpimOdoUt4,7540
163
- basic_memory/indexing/accepted_note_mutation_runner.py,sha256=NDa_SrxFT_f_Fj1JSQY-c0Uyk51TQiwJqInt1E9UPck,43587
163
+ basic_memory/indexing/accepted_note_mutation_runner.py,sha256=BTaMDcE2GpF4S5uhF2tHzxFbBOy1fOgUVjmB2_Wyj0M,47564
164
164
  basic_memory/indexing/accepted_note_search.py,sha256=fNcuxOKJ_zVM6FVlZQ8-OGjG3E3NXGJwkTuvO8aF0PU,4748
165
165
  basic_memory/indexing/accepted_note_write_runner.py,sha256=ZvQlX47C8oD-VTCBWSEDCzTE3x938IPetZQp48_H_PM,25759
166
166
  basic_memory/indexing/batch_indexer.py,sha256=Yy9CcZcHzUsLG24aAsaUVrCjkDFemAQeagDmr8IFV_s,37976
@@ -245,7 +245,7 @@ basic_memory/mcp/tools/chatgpt_tools.py,sha256=BpBLzT4zGe7TMLoFWbbnTq9lwcVS_1_F1
245
245
  basic_memory/mcp/tools/delete_note.py,sha256=RPFXo1QxxMV6PFw4Uf9eXdOBUpn_m8rJkVr4pFSVflI,20822
246
246
  basic_memory/mcp/tools/edit_note.py,sha256=owbnPnncW__O0cBAR0sZcLo37b4tTFtFtNYatTVhiGA,44695
247
247
  basic_memory/mcp/tools/list_directory.py,sha256=YQT4AmEfbmyHOWGYlU0s-htNBvZBzonA7gZQ-btiNkg,10367
248
- basic_memory/mcp/tools/move_note.py,sha256=bP_nYFCWQsqmiCe0uBNl1rJoX2b3rZiNXXxfjQ0MIJo,44360
248
+ basic_memory/mcp/tools/move_note.py,sha256=DzsB7sQU0Q-KlVGELN3kb9ivXfbqEiVNfFTNzxVFoqc,45139
249
249
  basic_memory/mcp/tools/project_management.py,sha256=B2MCcX7PCjAobfJkDCI5KiJkT4AVE43yNXGpZoICSpU,36341
250
250
  basic_memory/mcp/tools/read_content.py,sha256=eaGy4bV1fF7KAoyP9QgautDsaJUR2u8GOqx0CG0p5Ug,12970
251
251
  basic_memory/mcp/tools/read_note.py,sha256=BS40PBOSHwf7LYdfZK1OiNhsJvOSccen3V81UR6AG_0,25636
@@ -391,8 +391,8 @@ basic_memory/services/search_service.py,sha256=8A1vXo5IIpKXL2gxEsCEP_tcKyRcnz4R5
391
391
  basic_memory/services/service.py,sha256=V-d_8gOV07zGIQDpL-Ksqs3ZN9l3qf3HZOK1f_YNTag,336
392
392
  basic_memory/templates/prompts/continue_conversation.hbs,sha256=trrDHSXA5S0JCbInMoUJL04xvCGRB_ku1RHNQHtl6ZI,3076
393
393
  basic_memory/templates/prompts/search.hbs,sha256=H1cCIsHKp4VC1GrH2KeUB8pGe5vXFPqb2VPotypmeCA,3098
394
- basic_memory-0.23.1.dev27.dist-info/METADATA,sha256=j0HNhCqLZhDGmlKdwxHIf7eWLNDXeKyhOgF8UEzKrPI,26848
395
- basic_memory-0.23.1.dev27.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
396
- basic_memory-0.23.1.dev27.dist-info/entry_points.txt,sha256=wvE2mRF6-Pg4weIYcfQ-86NOLZD4WJg7F7TIsRVFLb8,90
397
- basic_memory-0.23.1.dev27.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
398
- basic_memory-0.23.1.dev27.dist-info/RECORD,,
394
+ basic_memory-0.23.2.dist-info/METADATA,sha256=wy0hbehVXk8xFwvXvdqv-oqzkmO8KyEbWzgqbZvP-Ps,26842
395
+ basic_memory-0.23.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
396
+ basic_memory-0.23.2.dist-info/entry_points.txt,sha256=wvE2mRF6-Pg4weIYcfQ-86NOLZD4WJg7F7TIsRVFLb8,90
397
+ basic_memory-0.23.2.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
398
+ basic_memory-0.23.2.dist-info/RECORD,,