basic-memory 0.23.1.dev24__py3-none-any.whl → 0.23.2.dev2__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 +1 -1
- basic_memory/api/v2/routers/project_router.py +22 -0
- basic_memory/cli/commands/doctor.py +19 -3
- basic_memory/indexing/accepted_note_mutation_runner.py +99 -7
- basic_memory/mcp/clients/project.py +7 -0
- basic_memory/mcp/tools/move_note.py +12 -1
- basic_memory/services/project_service.py +36 -22
- basic_memory/utils.py +46 -0
- {basic_memory-0.23.1.dev24.dist-info → basic_memory-0.23.2.dev2.dist-info}/METADATA +1 -1
- {basic_memory-0.23.1.dev24.dist-info → basic_memory-0.23.2.dev2.dist-info}/RECORD +13 -13
- {basic_memory-0.23.1.dev24.dist-info → basic_memory-0.23.2.dev2.dist-info}/WHEEL +0 -0
- {basic_memory-0.23.1.dev24.dist-info → basic_memory-0.23.2.dev2.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.23.1.dev24.dist-info → basic_memory-0.23.2.dev2.dist-info}/licenses/LICENSE +0 -0
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.
|
|
4
|
+
__version__ = "0.23.1"
|
|
5
5
|
|
|
6
6
|
# API version for FastAPI - independent of package version
|
|
7
7
|
__api_version__ = "v2"
|
|
@@ -221,6 +221,28 @@ async def add_project(
|
|
|
221
221
|
raise HTTPException(status_code=400, detail=str(e))
|
|
222
222
|
|
|
223
223
|
|
|
224
|
+
@router.post("/doctor", response_model=ProjectStatusResponse, status_code=201)
|
|
225
|
+
async def add_doctor_project(project_service: ProjectServiceDep) -> ProjectStatusResponse:
|
|
226
|
+
"""Create a server-generated disposable project for the local doctor check."""
|
|
227
|
+
try:
|
|
228
|
+
new_project = await project_service.add_doctor_project()
|
|
229
|
+
except ValueError as e:
|
|
230
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
231
|
+
|
|
232
|
+
return ProjectStatusResponse(
|
|
233
|
+
message=f"Project '{new_project.name}' added successfully",
|
|
234
|
+
status="success",
|
|
235
|
+
default=new_project.is_default or False,
|
|
236
|
+
new_project=ProjectItem(
|
|
237
|
+
id=new_project.id,
|
|
238
|
+
external_id=new_project.external_id,
|
|
239
|
+
name=new_project.name,
|
|
240
|
+
path=new_project.path,
|
|
241
|
+
is_default=new_project.is_default or False,
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
224
246
|
@router.post("/config/sync", response_model=ProjectStatusResponse)
|
|
225
247
|
async def synchronize_projects(
|
|
226
248
|
project_service: ProjectServiceDep,
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import tempfile
|
|
6
6
|
import uuid
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
8
9
|
|
|
9
10
|
from loguru import logger
|
|
10
11
|
from rich.console import Console
|
|
@@ -23,6 +24,14 @@ from basic_memory.schemas import ProjectIndexRunResponse
|
|
|
23
24
|
console = Console()
|
|
24
25
|
|
|
25
26
|
|
|
27
|
+
class DoctorProjectClient(Protocol):
|
|
28
|
+
"""Project-client capability required to clean up doctor state."""
|
|
29
|
+
|
|
30
|
+
async def delete_project(
|
|
31
|
+
self, project_external_id: str, delete_notes: bool = False
|
|
32
|
+
) -> object: ...
|
|
33
|
+
|
|
34
|
+
|
|
26
35
|
def _is_default_project_delete_error(error: Exception) -> bool:
|
|
27
36
|
"""Return True only for the API guard that blocks deleting the default project."""
|
|
28
37
|
error_text = str(error)
|
|
@@ -61,14 +70,14 @@ async def _delete_doctor_project_locally(project_name: str, project_id: str) ->
|
|
|
61
70
|
|
|
62
71
|
|
|
63
72
|
async def _delete_doctor_project(
|
|
64
|
-
project_client:
|
|
73
|
+
project_client: DoctorProjectClient, project_name: str, project_id: str
|
|
65
74
|
) -> None:
|
|
66
75
|
"""Delete the generated doctor project without weakening the public API guard."""
|
|
67
76
|
# Deferred: ToolError lives in FastMCP's runtime, which must not load at CLI startup (#886).
|
|
68
77
|
from fastmcp.exceptions import ToolError
|
|
69
78
|
|
|
70
79
|
try:
|
|
71
|
-
await project_client.delete_project(project_id)
|
|
80
|
+
await project_client.delete_project(project_id, delete_notes=True)
|
|
72
81
|
except ToolError as exc:
|
|
73
82
|
if not _is_default_project_delete_error(exc):
|
|
74
83
|
raise
|
|
@@ -127,9 +136,16 @@ async def run_doctor() -> None:
|
|
|
127
136
|
project_id: str | None = None
|
|
128
137
|
|
|
129
138
|
try:
|
|
130
|
-
|
|
139
|
+
from basic_memory.config import ConfigManager
|
|
140
|
+
|
|
141
|
+
status = (
|
|
142
|
+
await project_client.create_doctor_project()
|
|
143
|
+
if ConfigManager().config.project_root
|
|
144
|
+
else await project_client.create_project(project_request.model_dump())
|
|
145
|
+
)
|
|
131
146
|
if not status.new_project:
|
|
132
147
|
raise ValueError("Failed to create doctor project")
|
|
148
|
+
project_name = status.new_project.name
|
|
133
149
|
project_id = status.new_project.external_id
|
|
134
150
|
# Use the resolved path from the server — when project_root is configured,
|
|
135
151
|
# the actual project directory differs from the requested temp_path
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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=
|
|
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
|
|
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=
|
|
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
|
|
@@ -78,6 +78,13 @@ class ProjectClient:
|
|
|
78
78
|
)
|
|
79
79
|
return ProjectStatusResponse.model_validate(response.json())
|
|
80
80
|
|
|
81
|
+
async def create_doctor_project(self) -> ProjectStatusResponse:
|
|
82
|
+
"""Create the server-generated disposable project used by doctor."""
|
|
83
|
+
from basic_memory.mcp.tools.utils import call_post
|
|
84
|
+
|
|
85
|
+
response = await call_post(self.http_client, "/v2/projects/doctor")
|
|
86
|
+
return ProjectStatusResponse.model_validate(response.json())
|
|
87
|
+
|
|
81
88
|
async def delete_project(
|
|
82
89
|
self, project_external_id: str, delete_notes: bool = False
|
|
83
90
|
) -> ProjectStatusResponse:
|
|
@@ -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
|
-
|
|
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}"
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import shutil
|
|
7
|
+
import uuid
|
|
7
8
|
from collections.abc import Callable
|
|
8
9
|
from datetime import datetime
|
|
9
10
|
from pathlib import Path
|
|
@@ -190,7 +191,9 @@ class ProjectService:
|
|
|
190
191
|
# Not nested in either direction
|
|
191
192
|
return False
|
|
192
193
|
|
|
193
|
-
async def add_project(
|
|
194
|
+
async def add_project(
|
|
195
|
+
self, name: str, path: str, set_default: bool = False, *, allow_nested: bool = False
|
|
196
|
+
) -> None:
|
|
194
197
|
"""Add a new project to the configuration and database.
|
|
195
198
|
|
|
196
199
|
Args:
|
|
@@ -240,27 +243,25 @@ class ProjectService:
|
|
|
240
243
|
f"In cloud mode, paths are normalized to lowercase to prevent case-sensitivity issues."
|
|
241
244
|
) # pragma: no cover
|
|
242
245
|
|
|
243
|
-
#
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
f"Projects cannot share directory trees."
|
|
263
|
-
)
|
|
246
|
+
# Doctor creates a disposable project inside the configured project root.
|
|
247
|
+
# It must be allowed to coexist with the existing root project.
|
|
248
|
+
if not allow_nested:
|
|
249
|
+
for existing in existing_projects:
|
|
250
|
+
if self._check_nested_paths(resolved_path, existing.path):
|
|
251
|
+
p_new = Path(resolved_path).resolve()
|
|
252
|
+
p_existing = Path(existing.path).resolve()
|
|
253
|
+
if p_new.is_relative_to(p_existing):
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"Cannot create project at '{resolved_path}': "
|
|
256
|
+
f"path is nested within existing project '{existing.name}' at '{existing.path}'. "
|
|
257
|
+
f"Projects cannot share directory trees."
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
raise ValueError(
|
|
261
|
+
f"Cannot create project at '{resolved_path}': "
|
|
262
|
+
f"existing project '{existing.name}' at '{existing.path}' is nested within this path. "
|
|
263
|
+
f"Projects cannot share directory trees."
|
|
264
|
+
)
|
|
264
265
|
|
|
265
266
|
# Ensure the project directory exists on disk.
|
|
266
267
|
# Trigger: project_root not set means local filesystem mode (not S3/cloud)
|
|
@@ -332,6 +333,19 @@ class ProjectService:
|
|
|
332
333
|
|
|
333
334
|
logger.info(f"Project '{name}' added at {resolved_path}")
|
|
334
335
|
|
|
336
|
+
async def add_doctor_project(self) -> Project:
|
|
337
|
+
"""Create a server-generated disposable project under the configured root."""
|
|
338
|
+
project_root = self.config_manager.config.project_root
|
|
339
|
+
if not project_root:
|
|
340
|
+
raise ValueError("Doctor projects require BASIC_MEMORY_PROJECT_ROOT")
|
|
341
|
+
|
|
342
|
+
project_name = f"doctor-{uuid.uuid4().hex[:8]}"
|
|
343
|
+
await self.add_project(project_name, project_root, allow_nested=True)
|
|
344
|
+
project = await self.get_project(project_name)
|
|
345
|
+
if project is None: # pragma: no cover
|
|
346
|
+
raise ValueError("Failed to retrieve doctor project")
|
|
347
|
+
return project
|
|
348
|
+
|
|
335
349
|
async def remove_project(self, name: str, delete_notes: bool = False) -> None:
|
|
336
350
|
"""Remove a project from configuration and database.
|
|
337
351
|
|
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.
|
|
3
|
+
Version: 0.23.2.dev2
|
|
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=
|
|
1
|
+
basic_memory/__init__.py,sha256=rHK-KinfjZzEkeUVmFnT-996yse86yhhjCpaHBNCd9k,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=
|
|
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
|
|
@@ -56,7 +56,7 @@ basic_memory/api/v2/routers/importer_router.py,sha256=LHdi0q7FKKTeynMl4qg4itOw51
|
|
|
56
56
|
basic_memory/api/v2/routers/inspect_router.py,sha256=4QDM89AzWShF1UTE6CDjX1R8_4aNs3DIm4SpUUsbWzA,8637
|
|
57
57
|
basic_memory/api/v2/routers/knowledge_router.py,sha256=-irLLXtYTUtlbqjHUwN6OhWn1jG8SN9mbeUp3uOUpwk,49572
|
|
58
58
|
basic_memory/api/v2/routers/memory_router.py,sha256=2BvV7zA_39Ld3ySjY5twy7AzaMncn70fUVN7IdhGX_o,7308
|
|
59
|
-
basic_memory/api/v2/routers/project_router.py,sha256=
|
|
59
|
+
basic_memory/api/v2/routers/project_router.py,sha256=LGXW0ijvz68jZw0I5AWpXKfAOUgu2egDQymMFGJql88,25737
|
|
60
60
|
basic_memory/api/v2/routers/prompt_router.py,sha256=QE69XMKE6MB-yhzvr1mONkkeBZwKA_Ui9Kt-5ZVKvdY,10449
|
|
61
61
|
basic_memory/api/v2/routers/resource_router.py,sha256=t_tXxqGgxYJqcPFKU9jbFBhJzSIjTnfzo6SQMGFV2Mo,7571
|
|
62
62
|
basic_memory/api/v2/routers/schema_router.py,sha256=B_T2OXXFKvC-VaBzzDa2IbBmIHnJr_fPN43_8n9rMYo,22254
|
|
@@ -77,7 +77,7 @@ basic_memory/cli/commands/ci.py,sha256=MW4pepm9472iMzbryA4p-r-SG-OPnFftshB-0S8LB
|
|
|
77
77
|
basic_memory/cli/commands/command_utils.py,sha256=9_xUeeg1-nsnUw3MdNXOc_rhVROqhXqEDcTD3E_ga3A,5008
|
|
78
78
|
basic_memory/cli/commands/config.py,sha256=8IBqPrcgklhvHkVFG8v9laO0PqZkvfLsZ_jgbPkFCb4,8700
|
|
79
79
|
basic_memory/cli/commands/db.py,sha256=aQcjHpff15pfpDTKKpV-zz38TIJNfkOFQsCWggqbbU8,22596
|
|
80
|
-
basic_memory/cli/commands/doctor.py,sha256=
|
|
80
|
+
basic_memory/cli/commands/doctor.py,sha256=51fp0Keazaid-KPaIKaR9xKU9ELSALxFurvXKNuVnVo,11324
|
|
81
81
|
basic_memory/cli/commands/format.py,sha256=pgXkk0ptU2quN0XzjURuapA5AqrIBu1y4j6J1t52ovI,6753
|
|
82
82
|
basic_memory/cli/commands/hook.py,sha256=tNtGzf4E40bhZQL6-SJwQpq8ehQmxm7EIae7O7-3jJg,60641
|
|
83
83
|
basic_memory/cli/commands/import_chatgpt.py,sha256=7SUGG_mmme0BuL4ov0lhb98VkTUldunCv-ehQirtwZE,3870
|
|
@@ -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=
|
|
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
|
|
@@ -224,7 +224,7 @@ basic_memory/mcp/clients/directory.py,sha256=C_fY7xo300ceF-heRBAktv7OffEnNAuPWx1
|
|
|
224
224
|
basic_memory/mcp/clients/inspect.py,sha256=TtiEOiJITDuFLtrdwMcoY-XBNuCK4C3NobzpQHdHoRU,2311
|
|
225
225
|
basic_memory/mcp/clients/knowledge.py,sha256=DtEsAkqCtZFc-v93KIuPyOFCRLFbbOQGPqEwOSMJytw,13757
|
|
226
226
|
basic_memory/mcp/clients/memory.py,sha256=qKSjKS7jJOKu7y-Lldsm561KU0G9OpRzA0tLf7l9FmI,4794
|
|
227
|
-
basic_memory/mcp/clients/project.py,sha256=
|
|
227
|
+
basic_memory/mcp/clients/project.py,sha256=0er3M6QMjepuKpbn4G6oz7IuVf4veQhDTHJL6iUE09c,7955
|
|
228
228
|
basic_memory/mcp/clients/resource.py,sha256=_FI7bOBAQwlEuv24wU1tsGIFR-riHcemgZSMJqOLBN4,2218
|
|
229
229
|
basic_memory/mcp/clients/schema.py,sha256=OnLDJnh-8OkELdMVDRBObjOsGfgaDzroryjzSP8aWyc,3618
|
|
230
230
|
basic_memory/mcp/clients/search.py,sha256=I7mJxVaQ8-igJzuMzxYaZgHGCwJAkjMHf2QIgYuZBjI,3030
|
|
@@ -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=
|
|
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
|
|
@@ -385,14 +385,14 @@ basic_memory/services/note_content_reads.py,sha256=3o6GI2VJFkA5PScQD4IlZDO_4rcXL
|
|
|
385
385
|
basic_memory/services/note_content_writes.py,sha256=wqnPnaUII2k_xlTBYMZAVlrc7zGX13M3el334F6z6hY,20530
|
|
386
386
|
basic_memory/services/note_preparation.py,sha256=POGmk0L_9a-eLIMmXmuKtN81QRabF0JyipZiIz0eNqY,38262
|
|
387
387
|
basic_memory/services/project_deletes.py,sha256=bnq8la1ZKsZEkcAg3Ew2onXHku9CWChr-BWDZA6lvuI,6010
|
|
388
|
-
basic_memory/services/project_service.py,sha256=
|
|
388
|
+
basic_memory/services/project_service.py,sha256=E6rpEwKz6ihynZjXxBlvxUbG69YqE8jxOgBI3zT1XWQ,62460
|
|
389
389
|
basic_memory/services/retrieval_inspect.py,sha256=gJq94oYV6ISeh-JeUAB8srMw8K3oGeMgzVXEOWAEzxA,23844
|
|
390
390
|
basic_memory/services/search_service.py,sha256=8A1vXo5IIpKXL2gxEsCEP_tcKyRcnz4R5xb6zijRuW0,40254
|
|
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.
|
|
395
|
-
basic_memory-0.23.
|
|
396
|
-
basic_memory-0.23.
|
|
397
|
-
basic_memory-0.23.
|
|
398
|
-
basic_memory-0.23.
|
|
394
|
+
basic_memory-0.23.2.dev2.dist-info/METADATA,sha256=7wBf6qEcvOBxodGRltbfT3yU6cRyzEMG7AhuNUBsHKc,26847
|
|
395
|
+
basic_memory-0.23.2.dev2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
396
|
+
basic_memory-0.23.2.dev2.dist-info/entry_points.txt,sha256=wvE2mRF6-Pg4weIYcfQ-86NOLZD4WJg7F7TIsRVFLb8,90
|
|
397
|
+
basic_memory-0.23.2.dev2.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
398
|
+
basic_memory-0.23.2.dev2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|