basic-memory 0.14.4__py3-none-any.whl → 0.15.1__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.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (84) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/directory_router.py +23 -2
  5. basic_memory/api/routers/knowledge_router.py +25 -8
  6. basic_memory/api/routers/project_router.py +100 -4
  7. basic_memory/cli/app.py +9 -28
  8. basic_memory/cli/auth.py +277 -0
  9. basic_memory/cli/commands/cloud/__init__.py +5 -0
  10. basic_memory/cli/commands/cloud/api_client.py +112 -0
  11. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  12. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  13. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  14. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  15. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  16. basic_memory/cli/commands/command_utils.py +43 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +77 -60
  19. basic_memory/cli/commands/project.py +154 -152
  20. basic_memory/cli/commands/status.py +25 -22
  21. basic_memory/cli/commands/sync.py +45 -228
  22. basic_memory/cli/commands/tool.py +87 -16
  23. basic_memory/cli/main.py +1 -0
  24. basic_memory/config.py +131 -21
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +27 -8
  27. basic_memory/file_utils.py +37 -13
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/markdown/plugins.py +9 -7
  30. basic_memory/mcp/async_client.py +124 -14
  31. basic_memory/mcp/project_context.py +141 -0
  32. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  33. basic_memory/mcp/prompts/continue_conversation.py +17 -16
  34. basic_memory/mcp/prompts/recent_activity.py +116 -32
  35. basic_memory/mcp/prompts/search.py +13 -12
  36. basic_memory/mcp/prompts/utils.py +11 -4
  37. basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
  38. basic_memory/mcp/resources/project_info.py +27 -11
  39. basic_memory/mcp/server.py +0 -37
  40. basic_memory/mcp/tools/__init__.py +5 -6
  41. basic_memory/mcp/tools/build_context.py +67 -56
  42. basic_memory/mcp/tools/canvas.py +38 -26
  43. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  44. basic_memory/mcp/tools/delete_note.py +81 -47
  45. basic_memory/mcp/tools/edit_note.py +155 -138
  46. basic_memory/mcp/tools/list_directory.py +112 -99
  47. basic_memory/mcp/tools/move_note.py +181 -101
  48. basic_memory/mcp/tools/project_management.py +113 -277
  49. basic_memory/mcp/tools/read_content.py +91 -74
  50. basic_memory/mcp/tools/read_note.py +152 -115
  51. basic_memory/mcp/tools/recent_activity.py +471 -68
  52. basic_memory/mcp/tools/search.py +105 -92
  53. basic_memory/mcp/tools/sync_status.py +136 -130
  54. basic_memory/mcp/tools/utils.py +4 -0
  55. basic_memory/mcp/tools/view_note.py +44 -33
  56. basic_memory/mcp/tools/write_note.py +151 -90
  57. basic_memory/models/knowledge.py +12 -6
  58. basic_memory/models/project.py +6 -2
  59. basic_memory/repository/entity_repository.py +89 -82
  60. basic_memory/repository/relation_repository.py +13 -0
  61. basic_memory/repository/repository.py +18 -5
  62. basic_memory/repository/search_repository.py +46 -2
  63. basic_memory/schemas/__init__.py +6 -0
  64. basic_memory/schemas/base.py +39 -11
  65. basic_memory/schemas/cloud.py +46 -0
  66. basic_memory/schemas/memory.py +90 -21
  67. basic_memory/schemas/project_info.py +9 -10
  68. basic_memory/schemas/sync_report.py +48 -0
  69. basic_memory/services/context_service.py +25 -11
  70. basic_memory/services/directory_service.py +124 -3
  71. basic_memory/services/entity_service.py +100 -48
  72. basic_memory/services/initialization.py +30 -11
  73. basic_memory/services/project_service.py +101 -24
  74. basic_memory/services/search_service.py +16 -8
  75. basic_memory/sync/sync_service.py +173 -34
  76. basic_memory/sync/watch_service.py +101 -40
  77. basic_memory/utils.py +14 -4
  78. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
  79. basic_memory-0.15.1.dist-info/RECORD +146 -0
  80. basic_memory/mcp/project_session.py +0 -120
  81. basic_memory-0.14.4.dist-info/RECORD +0 -133
  82. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  83. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  84. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,818 @@
1
+ """Cloud bisync commands for Basic Memory CLI."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ import time
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
15
+ from basic_memory.cli.commands.cloud.rclone_config import (
16
+ add_tenant_to_rclone_config,
17
+ )
18
+ from basic_memory.cli.commands.cloud.rclone_installer import RcloneInstallError, install_rclone
19
+ from basic_memory.config import ConfigManager
20
+ from basic_memory.ignore_utils import get_bmignore_path, create_default_bmignore
21
+ from basic_memory.schemas.cloud import (
22
+ TenantMountInfo,
23
+ MountCredentials,
24
+ CloudProjectList,
25
+ CloudProjectCreateRequest,
26
+ CloudProjectCreateResponse,
27
+ )
28
+ from basic_memory.utils import generate_permalink
29
+
30
+ console = Console()
31
+
32
+
33
+ class BisyncError(Exception):
34
+ """Exception raised for bisync-related errors."""
35
+
36
+ pass
37
+
38
+
39
+ class RcloneBisyncProfile:
40
+ """Bisync profile with safety settings."""
41
+
42
+ def __init__(
43
+ self,
44
+ name: str,
45
+ conflict_resolve: str,
46
+ max_delete: int,
47
+ check_access: bool,
48
+ description: str,
49
+ extra_args: Optional[list[str]] = None,
50
+ ):
51
+ self.name = name
52
+ self.conflict_resolve = conflict_resolve
53
+ self.max_delete = max_delete
54
+ self.check_access = check_access
55
+ self.description = description
56
+ self.extra_args = extra_args or []
57
+
58
+
59
+ # Bisync profiles based on SPEC-9 Phase 2.1
60
+ BISYNC_PROFILES = {
61
+ "safe": RcloneBisyncProfile(
62
+ name="safe",
63
+ conflict_resolve="none",
64
+ max_delete=10,
65
+ check_access=False,
66
+ description="Safe mode with conflict preservation (keeps both versions)",
67
+ ),
68
+ "balanced": RcloneBisyncProfile(
69
+ name="balanced",
70
+ conflict_resolve="newer",
71
+ max_delete=25,
72
+ check_access=False,
73
+ description="Balanced mode - auto-resolve to newer file (recommended)",
74
+ ),
75
+ "fast": RcloneBisyncProfile(
76
+ name="fast",
77
+ conflict_resolve="newer",
78
+ max_delete=50,
79
+ check_access=False,
80
+ description="Fast mode for rapid iteration (skip verification)",
81
+ ),
82
+ }
83
+
84
+
85
+ async def get_mount_info() -> TenantMountInfo:
86
+ """Get current tenant information from cloud API."""
87
+ try:
88
+ config_manager = ConfigManager()
89
+ config = config_manager.config
90
+ host_url = config.cloud_host.rstrip("/")
91
+
92
+ response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
93
+
94
+ return TenantMountInfo.model_validate(response.json())
95
+ except Exception as e:
96
+ raise BisyncError(f"Failed to get tenant info: {e}") from e
97
+
98
+
99
+ async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
100
+ """Generate scoped credentials for syncing."""
101
+ try:
102
+ config_manager = ConfigManager()
103
+ config = config_manager.config
104
+ host_url = config.cloud_host.rstrip("/")
105
+
106
+ response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
107
+
108
+ return MountCredentials.model_validate(response.json())
109
+ except Exception as e:
110
+ raise BisyncError(f"Failed to generate credentials: {e}") from e
111
+
112
+
113
+ async def fetch_cloud_projects() -> CloudProjectList:
114
+ """Fetch list of projects from cloud API.
115
+
116
+ Returns:
117
+ CloudProjectList with projects from cloud
118
+ """
119
+ try:
120
+ config_manager = ConfigManager()
121
+ config = config_manager.config
122
+ host_url = config.cloud_host.rstrip("/")
123
+
124
+ response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
125
+
126
+ return CloudProjectList.model_validate(response.json())
127
+ except Exception as e:
128
+ raise BisyncError(f"Failed to fetch cloud projects: {e}") from e
129
+
130
+
131
+ def scan_local_directories(sync_dir: Path) -> list[str]:
132
+ """Scan local sync directory for project folders.
133
+
134
+ Args:
135
+ sync_dir: Path to bisync directory
136
+
137
+ Returns:
138
+ List of directory names (project names)
139
+ """
140
+ if not sync_dir.exists():
141
+ return []
142
+
143
+ directories = []
144
+ for item in sync_dir.iterdir():
145
+ if item.is_dir() and not item.name.startswith("."):
146
+ directories.append(item.name)
147
+
148
+ return directories
149
+
150
+
151
+ async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
152
+ """Create a new project on cloud.
153
+
154
+ Args:
155
+ project_name: Name of project to create
156
+
157
+ Returns:
158
+ CloudProjectCreateResponse with project details from API
159
+ """
160
+ try:
161
+ config_manager = ConfigManager()
162
+ config = config_manager.config
163
+ host_url = config.cloud_host.rstrip("/")
164
+
165
+ # Use generate_permalink to ensure consistent naming
166
+ project_path = generate_permalink(project_name)
167
+
168
+ project_data = CloudProjectCreateRequest(
169
+ name=project_name,
170
+ path=project_path,
171
+ set_default=False,
172
+ )
173
+
174
+ response = await make_api_request(
175
+ method="POST",
176
+ url=f"{host_url}/proxy/projects/projects",
177
+ headers={"Content-Type": "application/json"},
178
+ json_data=project_data.model_dump(),
179
+ )
180
+
181
+ return CloudProjectCreateResponse.model_validate(response.json())
182
+ except Exception as e:
183
+ raise BisyncError(f"Failed to create cloud project '{project_name}': {e}") from e
184
+
185
+
186
+ def get_bisync_state_path(tenant_id: str) -> Path:
187
+ """Get path to bisync state directory."""
188
+ return Path.home() / ".basic-memory" / "bisync-state" / tenant_id
189
+
190
+
191
+ def get_bisync_directory() -> Path:
192
+ """Get bisync directory from config.
193
+
194
+ Returns:
195
+ Path to bisync directory (default: ~/basic-memory-cloud-sync)
196
+ """
197
+ config_manager = ConfigManager()
198
+ config = config_manager.config
199
+
200
+ sync_dir = config.bisync_config.get("sync_dir", str(Path.home() / "basic-memory-cloud-sync"))
201
+ return Path(sync_dir).expanduser().resolve()
202
+
203
+
204
+ def validate_bisync_directory(bisync_dir: Path) -> None:
205
+ """Validate bisync directory doesn't conflict with mount.
206
+
207
+ Raises:
208
+ BisyncError: If bisync directory conflicts with mount directory
209
+ """
210
+ # Get fixed mount directory
211
+ mount_dir = (Path.home() / "basic-memory-cloud").resolve()
212
+
213
+ # Check if bisync dir is the same as mount dir
214
+ if bisync_dir == mount_dir:
215
+ raise BisyncError(
216
+ f"Cannot use {bisync_dir} for bisync - it's the mount directory!\n"
217
+ f"Mount and bisync must use different directories.\n\n"
218
+ f"Options:\n"
219
+ f" 1. Use default: ~/basic-memory-cloud-sync/\n"
220
+ f" 2. Specify different directory: --dir ~/my-sync-folder"
221
+ )
222
+
223
+ # Check if mount is active at this location
224
+ result = subprocess.run(["mount"], capture_output=True, text=True)
225
+ if str(bisync_dir) in result.stdout and "rclone" in result.stdout:
226
+ raise BisyncError(
227
+ f"{bisync_dir} is currently mounted via 'bm cloud mount'\n"
228
+ f"Cannot use mounted directory for bisync.\n\n"
229
+ f"Either:\n"
230
+ f" 1. Unmount first: bm cloud unmount\n"
231
+ f" 2. Use different directory for bisync"
232
+ )
233
+
234
+
235
+ def convert_bmignore_to_rclone_filters() -> Path:
236
+ """Convert .bmignore patterns to rclone filter format.
237
+
238
+ Reads ~/.basic-memory/.bmignore (gitignore-style) and converts to
239
+ ~/.basic-memory/.bmignore.rclone (rclone filter format).
240
+
241
+ Only regenerates if .bmignore has been modified since last conversion.
242
+
243
+ Returns:
244
+ Path to converted rclone filter file
245
+ """
246
+ # Ensure .bmignore exists
247
+ create_default_bmignore()
248
+
249
+ bmignore_path = get_bmignore_path()
250
+ # Create rclone filter path: ~/.basic-memory/.bmignore -> ~/.basic-memory/.bmignore.rclone
251
+ rclone_filter_path = bmignore_path.parent / f"{bmignore_path.name}.rclone"
252
+
253
+ # Skip regeneration if rclone file is newer than bmignore
254
+ if rclone_filter_path.exists():
255
+ bmignore_mtime = bmignore_path.stat().st_mtime
256
+ rclone_mtime = rclone_filter_path.stat().st_mtime
257
+ if rclone_mtime >= bmignore_mtime:
258
+ return rclone_filter_path
259
+
260
+ # Read .bmignore patterns
261
+ patterns = []
262
+ try:
263
+ with bmignore_path.open("r", encoding="utf-8") as f:
264
+ for line in f:
265
+ line = line.strip()
266
+ # Keep comments and empty lines
267
+ if not line or line.startswith("#"):
268
+ patterns.append(line)
269
+ continue
270
+
271
+ # Convert gitignore pattern to rclone filter syntax
272
+ # gitignore: node_modules → rclone: - node_modules/**
273
+ # gitignore: *.pyc → rclone: - *.pyc
274
+ if "*" in line:
275
+ # Pattern already has wildcard, just add exclude prefix
276
+ patterns.append(f"- {line}")
277
+ else:
278
+ # Directory pattern - add /** for recursive exclude
279
+ patterns.append(f"- {line}/**")
280
+
281
+ except Exception:
282
+ # If we can't read the file, create a minimal filter
283
+ patterns = ["# Error reading .bmignore, using minimal filters", "- .git/**"]
284
+
285
+ # Write rclone filter file
286
+ rclone_filter_path.write_text("\n".join(patterns) + "\n")
287
+
288
+ return rclone_filter_path
289
+
290
+
291
+ def get_bisync_filter_path() -> Path:
292
+ """Get path to bisync filter file.
293
+
294
+ Uses ~/.basic-memory/.bmignore (converted to rclone format).
295
+ The file is automatically created with default patterns on first use.
296
+
297
+ Returns:
298
+ Path to rclone filter file
299
+ """
300
+ return convert_bmignore_to_rclone_filters()
301
+
302
+
303
+ def bisync_state_exists(tenant_id: str) -> bool:
304
+ """Check if bisync state exists (has been initialized)."""
305
+ state_path = get_bisync_state_path(tenant_id)
306
+ return state_path.exists() and any(state_path.iterdir())
307
+
308
+
309
+ def build_bisync_command(
310
+ tenant_id: str,
311
+ bucket_name: str,
312
+ local_path: Path,
313
+ profile: RcloneBisyncProfile,
314
+ dry_run: bool = False,
315
+ resync: bool = False,
316
+ verbose: bool = False,
317
+ ) -> list[str]:
318
+ """Build rclone bisync command with profile settings."""
319
+
320
+ # Sync with the entire bucket root (all projects)
321
+ rclone_remote = f"basic-memory-{tenant_id}:{bucket_name}"
322
+ filter_path = get_bisync_filter_path()
323
+ state_path = get_bisync_state_path(tenant_id)
324
+
325
+ # Ensure state directory exists
326
+ state_path.mkdir(parents=True, exist_ok=True)
327
+
328
+ cmd = [
329
+ "rclone",
330
+ "bisync",
331
+ str(local_path),
332
+ rclone_remote,
333
+ "--create-empty-src-dirs",
334
+ "--resilient",
335
+ f"--conflict-resolve={profile.conflict_resolve}",
336
+ f"--max-delete={profile.max_delete}",
337
+ "--filters-file",
338
+ str(filter_path),
339
+ "--workdir",
340
+ str(state_path),
341
+ ]
342
+
343
+ # Add verbosity flags
344
+ if verbose:
345
+ cmd.append("--verbose") # Full details with file-by-file output
346
+ else:
347
+ # Show progress bar during transfers
348
+ cmd.append("--progress")
349
+
350
+ if profile.check_access:
351
+ cmd.append("--check-access")
352
+
353
+ if dry_run:
354
+ cmd.append("--dry-run")
355
+
356
+ if resync:
357
+ cmd.append("--resync")
358
+
359
+ cmd.extend(profile.extra_args)
360
+
361
+ return cmd
362
+
363
+
364
+ def setup_cloud_bisync(sync_dir: Optional[str] = None) -> None:
365
+ """Set up cloud bisync with rclone installation and configuration.
366
+
367
+ Args:
368
+ sync_dir: Optional custom sync directory path. If not provided, uses config default.
369
+ """
370
+ console.print("[bold blue]Basic Memory Cloud Bisync Setup[/bold blue]")
371
+ console.print("Setting up bidirectional sync to your cloud tenant...\n")
372
+
373
+ try:
374
+ # Step 1: Install rclone
375
+ console.print("[blue]Step 1: Installing rclone...[/blue]")
376
+ install_rclone()
377
+
378
+ # Step 2: Get mount info (for tenant_id, bucket)
379
+ console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
380
+ tenant_info = asyncio.run(get_mount_info())
381
+
382
+ tenant_id = tenant_info.tenant_id
383
+ bucket_name = tenant_info.bucket_name
384
+
385
+ console.print(f"[green]✓ Found tenant: {tenant_id}[/green]")
386
+ console.print(f"[green]✓ Bucket: {bucket_name}[/green]")
387
+
388
+ # Step 3: Generate credentials
389
+ console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
390
+ creds = asyncio.run(generate_mount_credentials(tenant_id))
391
+
392
+ access_key = creds.access_key
393
+ secret_key = creds.secret_key
394
+
395
+ console.print("[green]✓ Generated secure credentials[/green]")
396
+
397
+ # Step 4: Configure rclone
398
+ console.print("\n[blue]Step 4: Configuring rclone...[/blue]")
399
+ add_tenant_to_rclone_config(
400
+ tenant_id=tenant_id,
401
+ bucket_name=bucket_name,
402
+ access_key=access_key,
403
+ secret_key=secret_key,
404
+ )
405
+
406
+ # Step 5: Configure and create local directory
407
+ console.print("\n[blue]Step 5: Configuring sync directory...[/blue]")
408
+
409
+ # If custom sync_dir provided, save to config
410
+ if sync_dir:
411
+ config_manager = ConfigManager()
412
+ config = config_manager.load_config()
413
+ config.bisync_config["sync_dir"] = sync_dir
414
+ config_manager.save_config(config)
415
+ console.print("[green]✓ Saved custom sync directory to config[/green]")
416
+
417
+ # Get bisync directory (from config or default)
418
+ local_path = get_bisync_directory()
419
+
420
+ # Validate bisync directory
421
+ validate_bisync_directory(local_path)
422
+
423
+ # Create directory
424
+ local_path.mkdir(parents=True, exist_ok=True)
425
+ console.print(f"[green]✓ Created sync directory: {local_path}[/green]")
426
+
427
+ # Step 6: Perform initial resync
428
+ console.print("\n[blue]Step 6: Performing initial sync...[/blue]")
429
+ console.print("[yellow]This will establish the baseline for bidirectional sync.[/yellow]")
430
+
431
+ run_bisync(
432
+ tenant_id=tenant_id,
433
+ bucket_name=bucket_name,
434
+ local_path=local_path,
435
+ profile_name="balanced",
436
+ resync=True,
437
+ )
438
+
439
+ console.print("\n[bold green]✓ Bisync setup completed successfully![/bold green]")
440
+ console.print("\nYour local files will now sync bidirectionally with the cloud!")
441
+ console.print(f"\nLocal directory: {local_path}")
442
+ console.print("\nUseful commands:")
443
+ console.print(" bm sync # Run sync (recommended)")
444
+ console.print(" bm sync --watch # Start watch mode")
445
+ console.print(" bm cloud status # Check sync status")
446
+ console.print(" bm cloud check # Verify file integrity")
447
+ console.print(" bm cloud bisync --dry-run # Preview changes (advanced)")
448
+
449
+ except (RcloneInstallError, BisyncError, CloudAPIError) as e:
450
+ console.print(f"\n[red]Setup failed: {e}[/red]")
451
+ raise typer.Exit(1)
452
+ except Exception as e:
453
+ console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
454
+ raise typer.Exit(1)
455
+
456
+
457
+ def run_bisync(
458
+ tenant_id: Optional[str] = None,
459
+ bucket_name: Optional[str] = None,
460
+ local_path: Optional[Path] = None,
461
+ profile_name: str = "balanced",
462
+ dry_run: bool = False,
463
+ resync: bool = False,
464
+ verbose: bool = False,
465
+ ) -> bool:
466
+ """Run rclone bisync with specified profile."""
467
+
468
+ try:
469
+ # Get tenant info if not provided
470
+ if not tenant_id or not bucket_name:
471
+ tenant_info = asyncio.run(get_mount_info())
472
+ tenant_id = tenant_info.tenant_id
473
+ bucket_name = tenant_info.bucket_name
474
+
475
+ # Set default local path if not provided
476
+ if not local_path:
477
+ local_path = get_bisync_directory()
478
+
479
+ # Validate bisync directory
480
+ validate_bisync_directory(local_path)
481
+
482
+ # Check if local path exists
483
+ if not local_path.exists():
484
+ raise BisyncError(
485
+ f"Local directory {local_path} does not exist. Run 'basic-memory cloud bisync-setup' first."
486
+ )
487
+
488
+ # Get bisync profile
489
+ if profile_name not in BISYNC_PROFILES:
490
+ raise BisyncError(
491
+ f"Unknown profile: {profile_name}. Available: {list(BISYNC_PROFILES.keys())}"
492
+ )
493
+
494
+ profile = BISYNC_PROFILES[profile_name]
495
+
496
+ # Auto-register projects before sync (unless dry-run or resync)
497
+ if not dry_run and not resync:
498
+ try:
499
+ console.print("[dim]Checking for new projects...[/dim]")
500
+
501
+ # Fetch cloud projects and extract directory names from paths
502
+ cloud_data = asyncio.run(fetch_cloud_projects())
503
+ cloud_projects = cloud_data.projects
504
+
505
+ # Extract directory names from cloud project paths
506
+ # Compare directory names, not project names
507
+ # Cloud path /app/data/basic-memory -> directory name "basic-memory"
508
+ cloud_dir_names = set()
509
+ for p in cloud_projects:
510
+ path = p.path
511
+ # Strip /app/data/ prefix if present (cloud mode)
512
+ if path.startswith("/app/data/"):
513
+ path = path[len("/app/data/") :]
514
+ # Get the last segment (directory name)
515
+ dir_name = Path(path).name
516
+ cloud_dir_names.add(dir_name)
517
+
518
+ # Scan local directories
519
+ local_dirs = scan_local_directories(local_path)
520
+
521
+ # Create missing cloud projects
522
+ new_projects = []
523
+ for dir_name in local_dirs:
524
+ if dir_name not in cloud_dir_names:
525
+ new_projects.append(dir_name)
526
+
527
+ if new_projects:
528
+ console.print(
529
+ f"[blue]Found {len(new_projects)} new local project(s), creating on cloud...[/blue]"
530
+ )
531
+ for project_name in new_projects:
532
+ try:
533
+ asyncio.run(create_cloud_project(project_name))
534
+ console.print(f"[green] ✓ Created project: {project_name}[/green]")
535
+ except BisyncError as e:
536
+ console.print(
537
+ f"[yellow] ⚠ Could not create {project_name}: {e}[/yellow]"
538
+ )
539
+ else:
540
+ console.print("[dim]All local projects already registered on cloud[/dim]")
541
+
542
+ except Exception as e:
543
+ console.print(f"[yellow]Warning: Project auto-registration failed: {e}[/yellow]")
544
+ console.print("[yellow]Continuing with sync anyway...[/yellow]")
545
+
546
+ # Check if first run and require resync
547
+ if not resync and not bisync_state_exists(tenant_id) and not dry_run:
548
+ raise BisyncError(
549
+ "First bisync requires --resync to establish baseline. "
550
+ "Run: basic-memory cloud bisync --resync"
551
+ )
552
+
553
+ # Build and execute bisync command
554
+ bisync_cmd = build_bisync_command(
555
+ tenant_id,
556
+ bucket_name,
557
+ local_path,
558
+ profile,
559
+ dry_run=dry_run,
560
+ resync=resync,
561
+ verbose=verbose,
562
+ )
563
+
564
+ if dry_run:
565
+ console.print("[yellow]DRY RUN MODE - No changes will be made[/yellow]")
566
+
567
+ console.print(
568
+ f"[blue]Running bisync with profile '{profile_name}' ({profile.description})...[/blue]"
569
+ )
570
+ console.print(f"[dim]Command: {' '.join(bisync_cmd)}[/dim]")
571
+ console.print() # Blank line before output
572
+
573
+ # Stream output in real-time so user sees progress
574
+ result = subprocess.run(bisync_cmd, text=True)
575
+
576
+ if result.returncode != 0:
577
+ raise BisyncError(f"Bisync command failed with code {result.returncode}")
578
+
579
+ console.print() # Blank line after output
580
+
581
+ if dry_run:
582
+ console.print("[green]✓ Dry run completed successfully[/green]")
583
+ elif resync:
584
+ console.print("[green]✓ Initial sync baseline established[/green]")
585
+ else:
586
+ console.print("[green]✓ Sync completed successfully[/green]")
587
+
588
+ # Notify container to refresh cache (if not dry run)
589
+ if not dry_run:
590
+ try:
591
+ asyncio.run(notify_container_sync(tenant_id))
592
+ except Exception as e:
593
+ console.print(f"[yellow]Warning: Could not notify container: {e}[/yellow]")
594
+
595
+ return True
596
+
597
+ except BisyncError:
598
+ raise
599
+ except Exception as e:
600
+ raise BisyncError(f"Unexpected error during bisync: {e}") from e
601
+
602
+
603
+ async def notify_container_sync(tenant_id: str) -> None:
604
+ """Sync all projects after bisync completes."""
605
+ try:
606
+ from basic_memory.cli.commands.command_utils import run_sync
607
+
608
+ # Fetch all projects and sync each one
609
+ cloud_data = await fetch_cloud_projects()
610
+ projects = cloud_data.projects
611
+
612
+ if not projects:
613
+ console.print("[dim]No projects to sync[/dim]")
614
+ return
615
+
616
+ console.print(f"[blue]Notifying cloud to index {len(projects)} project(s)...[/blue]")
617
+
618
+ for project in projects:
619
+ project_name = project.name
620
+ if project_name:
621
+ try:
622
+ await run_sync(project=project_name)
623
+ except Exception as e:
624
+ # Non-critical, log and continue
625
+ console.print(f"[yellow] ⚠ Sync failed for {project_name}: {e}[/yellow]")
626
+
627
+ console.print("[dim]Note: Cloud indexing has started and may take a few moments[/dim]")
628
+
629
+ except Exception as e:
630
+ # Non-critical, don't fail the bisync
631
+ console.print(f"[yellow]Warning: Post-sync failed: {e}[/yellow]")
632
+
633
+
634
+ def run_bisync_watch(
635
+ tenant_id: Optional[str] = None,
636
+ bucket_name: Optional[str] = None,
637
+ local_path: Optional[Path] = None,
638
+ profile_name: str = "balanced",
639
+ interval_seconds: int = 60,
640
+ ) -> None:
641
+ """Run bisync in watch mode with periodic syncs."""
642
+
643
+ console.print("[bold blue]Starting bisync watch mode[/bold blue]")
644
+ console.print(f"Sync interval: {interval_seconds} seconds")
645
+ console.print("Press Ctrl+C to stop\n")
646
+
647
+ try:
648
+ while True:
649
+ try:
650
+ start_time = time.time()
651
+
652
+ run_bisync(
653
+ tenant_id=tenant_id,
654
+ bucket_name=bucket_name,
655
+ local_path=local_path,
656
+ profile_name=profile_name,
657
+ )
658
+
659
+ elapsed = time.time() - start_time
660
+ console.print(f"[dim]Sync completed in {elapsed:.1f}s[/dim]")
661
+
662
+ # Wait for next interval
663
+ time.sleep(interval_seconds)
664
+
665
+ except BisyncError as e:
666
+ console.print(f"[red]Sync error: {e}[/red]")
667
+ console.print(f"[yellow]Retrying in {interval_seconds} seconds...[/yellow]")
668
+ time.sleep(interval_seconds)
669
+
670
+ except KeyboardInterrupt:
671
+ console.print("\n[yellow]Watch mode stopped[/yellow]")
672
+
673
+
674
+ def show_bisync_status() -> None:
675
+ """Show current bisync status and configuration."""
676
+
677
+ try:
678
+ # Get tenant info
679
+ tenant_info = asyncio.run(get_mount_info())
680
+ tenant_id = tenant_info.tenant_id
681
+
682
+ local_path = get_bisync_directory()
683
+ state_path = get_bisync_state_path(tenant_id)
684
+
685
+ # Create status table
686
+ table = Table(title="Cloud Bisync Status", show_header=True, header_style="bold blue")
687
+ table.add_column("Property", style="green", min_width=20)
688
+ table.add_column("Value", style="dim", min_width=30)
689
+
690
+ # Check initialization status
691
+ is_initialized = bisync_state_exists(tenant_id)
692
+ init_status = (
693
+ "[green]✓ Initialized[/green]" if is_initialized else "[red]✗ Not initialized[/red]"
694
+ )
695
+
696
+ table.add_row("Tenant ID", tenant_id)
697
+ table.add_row("Local Directory", str(local_path))
698
+ table.add_row("Status", init_status)
699
+ table.add_row("State Directory", str(state_path))
700
+
701
+ # Check for last sync info
702
+ if is_initialized:
703
+ # Look for most recent state file
704
+ state_files = list(state_path.glob("*.lst"))
705
+ if state_files:
706
+ latest = max(state_files, key=lambda p: p.stat().st_mtime)
707
+ last_sync = datetime.fromtimestamp(latest.stat().st_mtime)
708
+ table.add_row("Last Sync", last_sync.strftime("%Y-%m-%d %H:%M:%S"))
709
+
710
+ console.print(table)
711
+
712
+ # Show bisync profiles
713
+ console.print("\n[bold]Available bisync profiles:[/bold]")
714
+ for name, profile in BISYNC_PROFILES.items():
715
+ console.print(f" {name}: {profile.description}")
716
+ console.print(f" - Conflict resolution: {profile.conflict_resolve}")
717
+ console.print(f" - Max delete: {profile.max_delete} files")
718
+
719
+ console.print("\n[dim]To use a profile: bm cloud bisync --profile <name>[/dim]")
720
+
721
+ # Show setup instructions if not initialized
722
+ if not is_initialized:
723
+ console.print("\n[yellow]To initialize bisync, run:[/yellow]")
724
+ console.print(" bm cloud setup")
725
+ console.print(" or")
726
+ console.print(" bm cloud bisync --resync")
727
+
728
+ except Exception as e:
729
+ console.print(f"[red]Error getting bisync status: {e}[/red]")
730
+ raise typer.Exit(1)
731
+
732
+
733
+ def run_check(
734
+ tenant_id: Optional[str] = None,
735
+ bucket_name: Optional[str] = None,
736
+ local_path: Optional[Path] = None,
737
+ one_way: bool = False,
738
+ ) -> bool:
739
+ """Check file integrity between local and cloud using rclone check.
740
+
741
+ Args:
742
+ tenant_id: Cloud tenant ID (auto-detected if not provided)
743
+ bucket_name: S3 bucket name (auto-detected if not provided)
744
+ local_path: Local bisync directory (uses config default if not provided)
745
+ one_way: If True, only check for missing files on destination (faster)
746
+
747
+ Returns:
748
+ True if check passed (files match), False if differences found
749
+ """
750
+ try:
751
+ # Check if rclone is installed
752
+ from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
753
+
754
+ if not is_rclone_installed():
755
+ raise BisyncError(
756
+ "rclone is not installed. Run 'bm cloud bisync-setup' first to set up cloud sync."
757
+ )
758
+
759
+ # Get tenant info if not provided
760
+ if not tenant_id or not bucket_name:
761
+ tenant_info = asyncio.run(get_mount_info())
762
+ tenant_id = tenant_id or tenant_info.tenant_id
763
+ bucket_name = bucket_name or tenant_info.bucket_name
764
+
765
+ # Get local path from config
766
+ if not local_path:
767
+ local_path = get_bisync_directory()
768
+
769
+ # Check if bisync is initialized
770
+ if not bisync_state_exists(tenant_id):
771
+ raise BisyncError(
772
+ "Bisync not initialized. Run 'bm cloud bisync --resync' to establish baseline."
773
+ )
774
+
775
+ # Build rclone check command
776
+ rclone_remote = f"basic-memory-{tenant_id}:{bucket_name}"
777
+ filter_path = get_bisync_filter_path()
778
+
779
+ cmd = [
780
+ "rclone",
781
+ "check",
782
+ str(local_path),
783
+ rclone_remote,
784
+ "--filter-from",
785
+ str(filter_path),
786
+ ]
787
+
788
+ if one_way:
789
+ cmd.append("--one-way")
790
+
791
+ console.print("[bold blue]Checking file integrity between local and cloud[/bold blue]")
792
+ console.print(f"[dim]Local: {local_path}[/dim]")
793
+ console.print(f"[dim]Remote: {rclone_remote}[/dim]")
794
+ console.print(f"[dim]Command: {' '.join(cmd)}[/dim]")
795
+ console.print()
796
+
797
+ # Run check command
798
+ result = subprocess.run(cmd, capture_output=True, text=True)
799
+
800
+ # rclone check returns:
801
+ # 0 = success (all files match)
802
+ # non-zero = differences found or error
803
+ if result.returncode == 0:
804
+ console.print("[green]✓ All files match between local and cloud[/green]")
805
+ return True
806
+ else:
807
+ console.print("[yellow]⚠ Differences found:[/yellow]")
808
+ if result.stderr:
809
+ console.print(result.stderr)
810
+ if result.stdout:
811
+ console.print(result.stdout)
812
+ console.print("\n[dim]To sync differences, run: bm sync[/dim]")
813
+ return False
814
+
815
+ except BisyncError:
816
+ raise
817
+ except Exception as e:
818
+ raise BisyncError(f"Check failed: {e}") from e