basic-memory 0.17.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.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,101 @@
1
+ """Shared utilities for cloud operations."""
2
+
3
+ from basic_memory.cli.commands.cloud.api_client import make_api_request
4
+ from basic_memory.config import ConfigManager
5
+ from basic_memory.schemas.cloud import (
6
+ CloudProjectList,
7
+ CloudProjectCreateRequest,
8
+ CloudProjectCreateResponse,
9
+ )
10
+ from basic_memory.utils import generate_permalink
11
+
12
+
13
+ class CloudUtilsError(Exception):
14
+ """Exception raised for cloud utility errors."""
15
+
16
+ pass
17
+
18
+
19
+ async def fetch_cloud_projects() -> CloudProjectList:
20
+ """Fetch list of projects from cloud API.
21
+
22
+ Returns:
23
+ CloudProjectList with projects from cloud
24
+ """
25
+ try:
26
+ config_manager = ConfigManager()
27
+ config = config_manager.config
28
+ host_url = config.cloud_host.rstrip("/")
29
+
30
+ response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
31
+
32
+ return CloudProjectList.model_validate(response.json())
33
+ except Exception as e:
34
+ raise CloudUtilsError(f"Failed to fetch cloud projects: {e}") from e
35
+
36
+
37
+ async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
38
+ """Create a new project on cloud.
39
+
40
+ Args:
41
+ project_name: Name of project to create
42
+
43
+ Returns:
44
+ CloudProjectCreateResponse with project details from API
45
+ """
46
+ try:
47
+ config_manager = ConfigManager()
48
+ config = config_manager.config
49
+ host_url = config.cloud_host.rstrip("/")
50
+
51
+ # Use generate_permalink to ensure consistent naming
52
+ project_path = generate_permalink(project_name)
53
+
54
+ project_data = CloudProjectCreateRequest(
55
+ name=project_name,
56
+ path=project_path,
57
+ set_default=False,
58
+ )
59
+
60
+ response = await make_api_request(
61
+ method="POST",
62
+ url=f"{host_url}/proxy/projects/projects",
63
+ headers={"Content-Type": "application/json"},
64
+ json_data=project_data.model_dump(),
65
+ )
66
+
67
+ return CloudProjectCreateResponse.model_validate(response.json())
68
+ except Exception as e:
69
+ raise CloudUtilsError(f"Failed to create cloud project '{project_name}': {e}") from e
70
+
71
+
72
+ async def sync_project(project_name: str, force_full: bool = False) -> None:
73
+ """Trigger sync for a specific project on cloud.
74
+
75
+ Args:
76
+ project_name: Name of project to sync
77
+ force_full: If True, force a full scan bypassing watermark optimization
78
+ """
79
+ try:
80
+ from basic_memory.cli.commands.command_utils import run_sync
81
+
82
+ await run_sync(project=project_name, force_full=force_full)
83
+ except Exception as e:
84
+ raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
85
+
86
+
87
+ async def project_exists(project_name: str) -> bool:
88
+ """Check if a project exists on cloud.
89
+
90
+ Args:
91
+ project_name: Name of project to check
92
+
93
+ Returns:
94
+ True if project exists, False otherwise
95
+ """
96
+ try:
97
+ projects = await fetch_cloud_projects()
98
+ project_names = {p.name for p in projects.projects}
99
+ return project_name in project_names
100
+ except Exception:
101
+ return False
@@ -0,0 +1,195 @@
1
+ """Core cloud commands for Basic Memory CLI."""
2
+
3
+ import asyncio
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from basic_memory.cli.app import cloud_app
9
+ from basic_memory.cli.auth import CLIAuth
10
+ from basic_memory.config import ConfigManager
11
+ from basic_memory.cli.commands.cloud.api_client import (
12
+ CloudAPIError,
13
+ SubscriptionRequiredError,
14
+ get_cloud_config,
15
+ make_api_request,
16
+ )
17
+ from basic_memory.cli.commands.cloud.bisync_commands import (
18
+ BisyncError,
19
+ generate_mount_credentials,
20
+ get_mount_info,
21
+ )
22
+ from basic_memory.cli.commands.cloud.rclone_config import configure_rclone_remote
23
+ from basic_memory.cli.commands.cloud.rclone_installer import (
24
+ RcloneInstallError,
25
+ install_rclone,
26
+ )
27
+
28
+ console = Console()
29
+
30
+
31
+ @cloud_app.command()
32
+ def login():
33
+ """Authenticate with WorkOS using OAuth Device Authorization flow and enable cloud mode."""
34
+
35
+ async def _login():
36
+ client_id, domain, host_url = get_cloud_config()
37
+ auth = CLIAuth(client_id=client_id, authkit_domain=domain)
38
+
39
+ try:
40
+ success = await auth.login()
41
+ if not success:
42
+ console.print("[red]Login failed[/red]")
43
+ raise typer.Exit(1)
44
+
45
+ # Test subscription access by calling a protected endpoint
46
+ console.print("[dim]Verifying subscription access...[/dim]")
47
+ await make_api_request("GET", f"{host_url.rstrip('/')}/proxy/health")
48
+
49
+ # Enable cloud mode after successful login and subscription validation
50
+ config_manager = ConfigManager()
51
+ config = config_manager.load_config()
52
+ config.cloud_mode = True
53
+ config_manager.save_config(config)
54
+
55
+ console.print("[green]Cloud mode enabled[/green]")
56
+ console.print(f"[dim]All CLI commands now work against {host_url}[/dim]")
57
+
58
+ except SubscriptionRequiredError as e:
59
+ console.print("\n[red]Subscription Required[/red]\n")
60
+ console.print(f"[yellow]{e.args[0]}[/yellow]\n")
61
+ console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
62
+ console.print(
63
+ "[dim]Once you have an active subscription, run [bold]bm cloud login[/bold] again.[/dim]"
64
+ )
65
+ raise typer.Exit(1)
66
+
67
+ asyncio.run(_login())
68
+
69
+
70
+ @cloud_app.command()
71
+ def logout():
72
+ """Disable cloud mode and return to local mode."""
73
+
74
+ # Disable cloud mode
75
+ config_manager = ConfigManager()
76
+ config = config_manager.load_config()
77
+ config.cloud_mode = False
78
+ config_manager.save_config(config)
79
+
80
+ console.print("[green]Cloud mode disabled[/green]")
81
+ console.print("[dim]All CLI commands now work locally[/dim]")
82
+
83
+
84
+ @cloud_app.command("status")
85
+ def status() -> None:
86
+ """Check cloud mode status and cloud instance health."""
87
+ # Check cloud mode
88
+ config_manager = ConfigManager()
89
+ config = config_manager.load_config()
90
+
91
+ console.print("[bold blue]Cloud Mode Status[/bold blue]")
92
+ if config.cloud_mode:
93
+ console.print(" Mode: [green]Cloud (enabled)[/green]")
94
+ console.print(f" Host: {config.cloud_host}")
95
+ console.print(" [dim]All CLI commands work against cloud[/dim]")
96
+ else:
97
+ console.print(" Mode: [yellow]Local (disabled)[/yellow]")
98
+ console.print(" [dim]All CLI commands work locally[/dim]")
99
+ console.print("\n[dim]To enable cloud mode, run: bm cloud login[/dim]")
100
+ return
101
+
102
+ # Get cloud configuration
103
+ _, _, host_url = get_cloud_config()
104
+ host_url = host_url.rstrip("/")
105
+
106
+ # Prepare headers
107
+ headers = {}
108
+
109
+ try:
110
+ console.print("\n[blue]Checking cloud instance health...[/blue]")
111
+
112
+ # Make API request to check health
113
+ response = asyncio.run(
114
+ make_api_request(method="GET", url=f"{host_url}/proxy/health", headers=headers)
115
+ )
116
+
117
+ health_data = response.json()
118
+
119
+ console.print("[green]Cloud instance is healthy[/green]")
120
+
121
+ # Display status details
122
+ if "status" in health_data:
123
+ console.print(f" Status: {health_data['status']}")
124
+ if "version" in health_data:
125
+ console.print(f" Version: {health_data['version']}")
126
+ if "timestamp" in health_data:
127
+ console.print(f" Timestamp: {health_data['timestamp']}")
128
+
129
+ console.print("\n[dim]To sync projects, use: bm project bisync --name <project>[/dim]")
130
+
131
+ except CloudAPIError as e:
132
+ console.print(f"[red]Error checking cloud health: {e}[/red]")
133
+ raise typer.Exit(1)
134
+ except Exception as e:
135
+ console.print(f"[red]Unexpected error: {e}[/red]")
136
+ raise typer.Exit(1)
137
+
138
+
139
+ @cloud_app.command("setup")
140
+ def setup() -> None:
141
+ """Set up cloud sync by installing rclone and configuring credentials.
142
+
143
+ SPEC-20: Simplified to project-scoped workflow.
144
+ After setup, use project commands for syncing:
145
+ bm project add <name> <path> --local-path ~/projects/<name>
146
+ bm project bisync --name <name> --resync # First time
147
+ bm project bisync --name <name> # Subsequent syncs
148
+ """
149
+ console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
150
+ console.print("Setting up cloud sync with rclone...\n")
151
+
152
+ try:
153
+ # Step 1: Install rclone
154
+ console.print("[blue]Step 1: Installing rclone...[/blue]")
155
+ install_rclone()
156
+
157
+ # Step 2: Get tenant info
158
+ console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
159
+ tenant_info = asyncio.run(get_mount_info())
160
+ console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
161
+
162
+ # Step 3: Generate credentials
163
+ console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
164
+ creds = asyncio.run(generate_mount_credentials(tenant_info.tenant_id))
165
+ console.print("[green]Generated secure credentials[/green]")
166
+
167
+ # Step 4: Configure rclone remote
168
+ console.print("\n[blue]Step 4: Configuring rclone remote...[/blue]")
169
+ configure_rclone_remote(
170
+ access_key=creds.access_key,
171
+ secret_key=creds.secret_key,
172
+ )
173
+
174
+ console.print("\n[bold green]Cloud setup completed successfully![/bold green]")
175
+ console.print("\n[bold]Next steps:[/bold]")
176
+ console.print("1. Add a project with local sync path:")
177
+ console.print(" bm project add research --local-path ~/Documents/research")
178
+ console.print("\n Or configure sync for an existing project:")
179
+ console.print(" bm project sync-setup research ~/Documents/research")
180
+ console.print("\n2. Preview the initial sync (recommended):")
181
+ console.print(" bm project bisync --name research --resync --dry-run")
182
+ console.print("\n3. If all looks good, run the actual sync:")
183
+ console.print(" bm project bisync --name research --resync")
184
+ console.print("\n4. Subsequent syncs (no --resync needed):")
185
+ console.print(" bm project bisync --name research")
186
+ console.print(
187
+ "\n[dim]Tip: Always use --dry-run first to preview changes before syncing[/dim]"
188
+ )
189
+
190
+ except (RcloneInstallError, BisyncError, CloudAPIError) as e:
191
+ console.print(f"\n[red]Setup failed: {e}[/red]")
192
+ raise typer.Exit(1)
193
+ except Exception as e:
194
+ console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
195
+ raise typer.Exit(1)
@@ -0,0 +1,371 @@
1
+ """Project-scoped rclone sync commands for Basic Memory Cloud.
2
+
3
+ This module provides simplified, project-scoped rclone operations:
4
+ - Each project syncs independently
5
+ - Uses single "basic-memory-cloud" remote (not tenant-specific)
6
+ - Balanced defaults from SPEC-8 Phase 4 testing
7
+ - Per-project bisync state tracking
8
+
9
+ Replaces tenant-wide sync with project-scoped workflows.
10
+ """
11
+
12
+ import re
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from loguru import logger
20
+ from rich.console import Console
21
+
22
+ from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
23
+ from basic_memory.utils import normalize_project_path
24
+
25
+ console = Console()
26
+
27
+ # Minimum rclone version for --create-empty-src-dirs support
28
+ MIN_RCLONE_VERSION_EMPTY_DIRS = (1, 64, 0)
29
+
30
+
31
+ class RcloneError(Exception):
32
+ """Exception raised for rclone command errors."""
33
+
34
+ pass
35
+
36
+
37
+ def check_rclone_installed() -> None:
38
+ """Check if rclone is installed and raise helpful error if not.
39
+
40
+ Raises:
41
+ RcloneError: If rclone is not installed with installation instructions
42
+ """
43
+ if not is_rclone_installed():
44
+ raise RcloneError(
45
+ "rclone is not installed.\n\n"
46
+ "Install rclone by running: bm cloud setup\n"
47
+ "Or install manually from: https://rclone.org/downloads/\n\n"
48
+ "Windows users: Ensure you have a package manager installed (winget, chocolatey, or scoop)"
49
+ )
50
+
51
+
52
+ @lru_cache(maxsize=1)
53
+ def get_rclone_version() -> tuple[int, int, int] | None:
54
+ """Get rclone version as (major, minor, patch) tuple.
55
+
56
+ Returns:
57
+ Version tuple like (1, 64, 2), or None if version cannot be determined.
58
+
59
+ Note:
60
+ Result is cached since rclone version won't change during runtime.
61
+ """
62
+ try:
63
+ result = subprocess.run(["rclone", "version"], capture_output=True, text=True, timeout=10)
64
+ # Parse "rclone v1.64.2" or "rclone v1.60.1-DEV"
65
+ match = re.search(r"v(\d+)\.(\d+)\.(\d+)", result.stdout)
66
+ if match:
67
+ version = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
68
+ logger.debug(f"Detected rclone version: {version}")
69
+ return version
70
+ except Exception as e:
71
+ logger.warning(f"Could not determine rclone version: {e}")
72
+ return None
73
+
74
+
75
+ def supports_create_empty_src_dirs() -> bool:
76
+ """Check if installed rclone supports --create-empty-src-dirs flag.
77
+
78
+ Returns:
79
+ True if rclone version >= 1.64.0, False otherwise.
80
+ """
81
+ version = get_rclone_version()
82
+ if version is None:
83
+ # If we can't determine version, assume older and skip the flag
84
+ return False
85
+ return version >= MIN_RCLONE_VERSION_EMPTY_DIRS
86
+
87
+
88
+ @dataclass
89
+ class SyncProject:
90
+ """Project configured for cloud sync.
91
+
92
+ Attributes:
93
+ name: Project name
94
+ path: Cloud path (e.g., "app/data/research")
95
+ local_sync_path: Local directory for syncing (optional)
96
+ """
97
+
98
+ name: str
99
+ path: str
100
+ local_sync_path: Optional[str] = None
101
+
102
+
103
+ def get_bmignore_filter_path() -> Path:
104
+ """Get path to rclone filter file.
105
+
106
+ Uses ~/.basic-memory/.bmignore converted to rclone format.
107
+ File is automatically created with default patterns on first use.
108
+
109
+ Returns:
110
+ Path to rclone filter file
111
+ """
112
+ # Import here to avoid circular dependency
113
+ from basic_memory.cli.commands.cloud.bisync_commands import (
114
+ convert_bmignore_to_rclone_filters,
115
+ )
116
+
117
+ return convert_bmignore_to_rclone_filters()
118
+
119
+
120
+ def get_project_bisync_state(project_name: str) -> Path:
121
+ """Get path to project's bisync state directory.
122
+
123
+ Args:
124
+ project_name: Name of the project
125
+
126
+ Returns:
127
+ Path to bisync state directory for this project
128
+ """
129
+ return Path.home() / ".basic-memory" / "bisync-state" / project_name
130
+
131
+
132
+ def bisync_initialized(project_name: str) -> bool:
133
+ """Check if bisync has been initialized for this project.
134
+
135
+ Args:
136
+ project_name: Name of the project
137
+
138
+ Returns:
139
+ True if bisync state exists, False otherwise
140
+ """
141
+ state_path = get_project_bisync_state(project_name)
142
+ return state_path.exists() and any(state_path.iterdir())
143
+
144
+
145
+ def get_project_remote(project: SyncProject, bucket_name: str) -> str:
146
+ """Build rclone remote path for project.
147
+
148
+ Args:
149
+ project: Project with cloud path
150
+ bucket_name: S3 bucket name
151
+
152
+ Returns:
153
+ Remote path like "basic-memory-cloud:bucket-name/basic-memory-llc"
154
+
155
+ Note:
156
+ The API returns paths like "/app/data/basic-memory-llc" because the S3 bucket
157
+ is mounted at /app/data on the fly machine. We need to strip the /app/data/
158
+ prefix to get the actual S3 path within the bucket.
159
+ """
160
+ # Normalize path to strip /app/data/ mount point prefix
161
+ cloud_path = normalize_project_path(project.path).lstrip("/")
162
+ return f"basic-memory-cloud:{bucket_name}/{cloud_path}"
163
+
164
+
165
+ def project_sync(
166
+ project: SyncProject,
167
+ bucket_name: str,
168
+ dry_run: bool = False,
169
+ verbose: bool = False,
170
+ ) -> bool:
171
+ """One-way sync: local → cloud.
172
+
173
+ Makes cloud identical to local using rclone sync.
174
+
175
+ Args:
176
+ project: Project to sync
177
+ bucket_name: S3 bucket name
178
+ dry_run: Preview changes without applying
179
+ verbose: Show detailed output
180
+
181
+ Returns:
182
+ True if sync succeeded, False otherwise
183
+
184
+ Raises:
185
+ RcloneError: If project has no local_sync_path configured or rclone not installed
186
+ """
187
+ check_rclone_installed()
188
+
189
+ if not project.local_sync_path:
190
+ raise RcloneError(f"Project {project.name} has no local_sync_path configured")
191
+
192
+ local_path = Path(project.local_sync_path).expanduser()
193
+ remote_path = get_project_remote(project, bucket_name)
194
+ filter_path = get_bmignore_filter_path()
195
+
196
+ cmd = [
197
+ "rclone",
198
+ "sync",
199
+ str(local_path),
200
+ remote_path,
201
+ "--filter-from",
202
+ str(filter_path),
203
+ ]
204
+
205
+ if verbose:
206
+ cmd.append("--verbose")
207
+ else:
208
+ cmd.append("--progress")
209
+
210
+ if dry_run:
211
+ cmd.append("--dry-run")
212
+
213
+ result = subprocess.run(cmd, text=True)
214
+ return result.returncode == 0
215
+
216
+
217
+ def project_bisync(
218
+ project: SyncProject,
219
+ bucket_name: str,
220
+ dry_run: bool = False,
221
+ resync: bool = False,
222
+ verbose: bool = False,
223
+ ) -> bool:
224
+ """Two-way sync: local ↔ cloud.
225
+
226
+ Uses rclone bisync with balanced defaults:
227
+ - conflict_resolve: newer (auto-resolve to most recent)
228
+ - max_delete: 25 (safety limit)
229
+ - compare: modtime (ignore size differences from line ending conversions)
230
+ - check_access: false (skip for performance)
231
+
232
+ Args:
233
+ project: Project to sync
234
+ bucket_name: S3 bucket name
235
+ dry_run: Preview changes without applying
236
+ resync: Force resync to establish new baseline
237
+ verbose: Show detailed output
238
+
239
+ Returns:
240
+ True if bisync succeeded, False otherwise
241
+
242
+ Raises:
243
+ RcloneError: If project has no local_sync_path, needs --resync, or rclone not installed
244
+ """
245
+ check_rclone_installed()
246
+
247
+ if not project.local_sync_path:
248
+ raise RcloneError(f"Project {project.name} has no local_sync_path configured")
249
+
250
+ local_path = Path(project.local_sync_path).expanduser()
251
+ remote_path = get_project_remote(project, bucket_name)
252
+ filter_path = get_bmignore_filter_path()
253
+ state_path = get_project_bisync_state(project.name)
254
+
255
+ # Ensure state directory exists
256
+ state_path.mkdir(parents=True, exist_ok=True)
257
+
258
+ cmd = [
259
+ "rclone",
260
+ "bisync",
261
+ str(local_path),
262
+ remote_path,
263
+ "--resilient",
264
+ "--conflict-resolve=newer",
265
+ "--max-delete=25",
266
+ "--compare=modtime", # Ignore size differences from line ending conversions
267
+ "--filter-from",
268
+ str(filter_path),
269
+ "--workdir",
270
+ str(state_path),
271
+ ]
272
+
273
+ # Add --create-empty-src-dirs if rclone version supports it (v1.64+)
274
+ if supports_create_empty_src_dirs():
275
+ cmd.append("--create-empty-src-dirs")
276
+
277
+ if verbose:
278
+ cmd.append("--verbose")
279
+ else:
280
+ cmd.append("--progress")
281
+
282
+ if dry_run:
283
+ cmd.append("--dry-run")
284
+
285
+ if resync:
286
+ cmd.append("--resync")
287
+
288
+ # Check if first run requires resync
289
+ if not resync and not bisync_initialized(project.name) and not dry_run:
290
+ raise RcloneError(
291
+ f"First bisync for {project.name} requires --resync to establish baseline.\n"
292
+ f"Run: bm project bisync --name {project.name} --resync"
293
+ )
294
+
295
+ result = subprocess.run(cmd, text=True)
296
+ return result.returncode == 0
297
+
298
+
299
+ def project_check(
300
+ project: SyncProject,
301
+ bucket_name: str,
302
+ one_way: bool = False,
303
+ ) -> bool:
304
+ """Check integrity between local and cloud.
305
+
306
+ Verifies files match without transferring data.
307
+
308
+ Args:
309
+ project: Project to check
310
+ bucket_name: S3 bucket name
311
+ one_way: Only check for missing files on destination (faster)
312
+
313
+ Returns:
314
+ True if files match, False if differences found
315
+
316
+ Raises:
317
+ RcloneError: If project has no local_sync_path configured or rclone not installed
318
+ """
319
+ check_rclone_installed()
320
+
321
+ if not project.local_sync_path:
322
+ raise RcloneError(f"Project {project.name} has no local_sync_path configured")
323
+
324
+ local_path = Path(project.local_sync_path).expanduser()
325
+ remote_path = get_project_remote(project, bucket_name)
326
+ filter_path = get_bmignore_filter_path()
327
+
328
+ cmd = [
329
+ "rclone",
330
+ "check",
331
+ str(local_path),
332
+ remote_path,
333
+ "--filter-from",
334
+ str(filter_path),
335
+ ]
336
+
337
+ if one_way:
338
+ cmd.append("--one-way")
339
+
340
+ result = subprocess.run(cmd, capture_output=True, text=True)
341
+ return result.returncode == 0
342
+
343
+
344
+ def project_ls(
345
+ project: SyncProject,
346
+ bucket_name: str,
347
+ path: Optional[str] = None,
348
+ ) -> list[str]:
349
+ """List files in remote project.
350
+
351
+ Args:
352
+ project: Project to list files from
353
+ bucket_name: S3 bucket name
354
+ path: Optional subdirectory within project
355
+
356
+ Returns:
357
+ List of file paths
358
+
359
+ Raises:
360
+ subprocess.CalledProcessError: If rclone command fails
361
+ RcloneError: If rclone is not installed
362
+ """
363
+ check_rclone_installed()
364
+
365
+ remote_path = get_project_remote(project, bucket_name)
366
+ if path:
367
+ remote_path = f"{remote_path}/{path}"
368
+
369
+ cmd = ["rclone", "ls", remote_path]
370
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
371
+ return result.stdout.splitlines()