fix: unify project path so path is always the local filesystem path

Cloud projects with bisync had a split-brain problem: `path` held a cloud
slug while the actual local directory lived in `local_sync_path`. This caused
`bm status` and file sync to fail for bisync'd cloud projects.

Changes:
- Config migration promotes `local_sync_path` → `path` for entries where
  `path` is a non-absolute cloud slug
- `ensure_project_paths_exists` skips cloud-only projects with slug paths
- `initialize_file_sync` and watch service now keep cloud projects that have
  an absolute local path (bisync copy) instead of skipping all cloud projects
- `sync-setup` and `project add --cloud --local-path` set both `path` and
  `local_sync_path` to the local directory
- `sync-setup` creates the project in the local DB for immediate MCP use
- `_get_sync_project` falls back from `local_sync_path` to `path`
- Config load errors now show user-friendly messages instead of stack traces

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2026-02-24 15:08:09 -06:00
parent 538af97cba
commit d763d86798
7 changed files with 195 additions and 38 deletions
+39 -2
View File
@@ -403,6 +403,18 @@ class BasicMemoryConfig(BaseSettings):
data.pop("project_modes", None)
data.pop("cloud_projects", None)
# --- Promote local_sync_path into path for cloud projects with slug paths ---
# Trigger: project entry has local_sync_path set but path is a cloud slug (not absolute)
# Why: path must always be the local filesystem path; the cloud remote is derivable
# Outcome: path becomes the local directory, local_sync_path kept for backwards compat
projects = data.get("projects", {})
for name, entry in projects.items():
if isinstance(entry, dict):
lsp = entry.get("local_sync_path")
path = entry.get("path", "")
if lsp and not os.path.isabs(path):
entry["path"] = lsp
return data
@property
@@ -564,6 +576,9 @@ class BasicMemoryConfig(BaseSettings):
for name, entry in self.projects.items():
path = Path(entry.path)
# Skip cloud-only projects whose path is a slug, not a local directory
if not path.is_absolute():
continue
if not path.exists():
try:
path.mkdir(parents=True)
@@ -645,6 +660,17 @@ class ConfigManager:
if isinstance(first_val, str):
needs_resave = True
# Check if any project has local_sync_path set but path is a cloud slug
# (will be migrated by migrate_legacy_projects validator)
if not needs_resave:
for entry_data in projects_raw.values():
if isinstance(entry_data, dict):
lsp = entry_data.get("local_sync_path")
p = entry_data.get("path", "")
if lsp and not os.path.isabs(p):
needs_resave = True
break
# First, create config from environment variables (Pydantic will read them)
# Then overlay with file data for fields that aren't set via env vars
# This ensures env vars take precedence
@@ -673,9 +699,20 @@ class ConfigManager:
save_basic_memory_config(self.config_file, _CONFIG_CACHE)
return _CONFIG_CACHE
except json.JSONDecodeError as e: # pragma: no cover
logger.error(f"Invalid JSON in config file {self.config_file}: {e}")
raise SystemExit(
f"Error: config file is not valid JSON: {self.config_file}\n"
f" {e}\n"
f"Fix or delete the file and re-run."
)
except Exception as e: # pragma: no cover
logger.exception(f"Failed to load config: {e}")
raise e
logger.error(f"Failed to load config from {self.config_file}: {e}")
raise SystemExit(
f"Error: failed to load config from {self.config_file}\n"
f" {e}\n"
f"Fix or delete the file and re-run."
)
else:
config = BasicMemoryConfig()
self.save_config(config)