basic-memory 0.7.0__py3-none-any.whl → 0.17.4__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 (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  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 +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  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 +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -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/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -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,397 @@
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 Callable, Optional, Protocol
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
+ class RunResult(Protocol):
31
+ returncode: int
32
+ stdout: str
33
+
34
+
35
+ RunFunc = Callable[..., RunResult]
36
+ IsInstalledFunc = Callable[[], bool]
37
+
38
+
39
+ class RcloneError(Exception):
40
+ """Exception raised for rclone command errors."""
41
+
42
+ pass
43
+
44
+
45
+ def check_rclone_installed(is_installed: IsInstalledFunc = is_rclone_installed) -> None:
46
+ """Check if rclone is installed and raise helpful error if not.
47
+
48
+ Raises:
49
+ RcloneError: If rclone is not installed with installation instructions
50
+ """
51
+ if not is_installed():
52
+ raise RcloneError(
53
+ "rclone is not installed.\n\n"
54
+ "Install rclone by running: bm cloud setup\n"
55
+ "Or install manually from: https://rclone.org/downloads/\n\n"
56
+ "Windows users: Ensure you have a package manager installed (winget, chocolatey, or scoop)"
57
+ )
58
+
59
+
60
+ @lru_cache(maxsize=1)
61
+ def get_rclone_version(run: RunFunc = subprocess.run) -> tuple[int, int, int] | None:
62
+ """Get rclone version as (major, minor, patch) tuple.
63
+
64
+ Returns:
65
+ Version tuple like (1, 64, 2), or None if version cannot be determined.
66
+
67
+ Note:
68
+ Result is cached since rclone version won't change during runtime.
69
+ """
70
+ try:
71
+ result = run(["rclone", "version"], capture_output=True, text=True, timeout=10)
72
+ # Parse "rclone v1.64.2" or "rclone v1.60.1-DEV"
73
+ match = re.search(r"v(\d+)\.(\d+)\.(\d+)", result.stdout)
74
+ if match:
75
+ version = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
76
+ logger.debug(f"Detected rclone version: {version}")
77
+ return version
78
+ except Exception as e:
79
+ logger.warning(f"Could not determine rclone version: {e}")
80
+ return None
81
+
82
+
83
+ def supports_create_empty_src_dirs(version: tuple[int, int, int] | None) -> bool:
84
+ """Check if installed rclone supports --create-empty-src-dirs flag.
85
+
86
+ Returns:
87
+ True if rclone version >= 1.64.0, False otherwise.
88
+ """
89
+ if version is None:
90
+ # If we can't determine version, assume older and skip the flag
91
+ return False
92
+ return version >= MIN_RCLONE_VERSION_EMPTY_DIRS
93
+
94
+
95
+ @dataclass
96
+ class SyncProject:
97
+ """Project configured for cloud sync.
98
+
99
+ Attributes:
100
+ name: Project name
101
+ path: Cloud path (e.g., "app/data/research")
102
+ local_sync_path: Local directory for syncing (optional)
103
+ """
104
+
105
+ name: str
106
+ path: str
107
+ local_sync_path: Optional[str] = None
108
+
109
+
110
+ def get_bmignore_filter_path() -> Path:
111
+ """Get path to rclone filter file.
112
+
113
+ Uses ~/.basic-memory/.bmignore converted to rclone format.
114
+ File is automatically created with default patterns on first use.
115
+
116
+ Returns:
117
+ Path to rclone filter file
118
+ """
119
+ # Import here to avoid circular dependency
120
+ from basic_memory.cli.commands.cloud.bisync_commands import (
121
+ convert_bmignore_to_rclone_filters,
122
+ )
123
+
124
+ return convert_bmignore_to_rclone_filters()
125
+
126
+
127
+ def get_project_bisync_state(project_name: str) -> Path:
128
+ """Get path to project's bisync state directory.
129
+
130
+ Args:
131
+ project_name: Name of the project
132
+
133
+ Returns:
134
+ Path to bisync state directory for this project
135
+ """
136
+ return Path.home() / ".basic-memory" / "bisync-state" / project_name
137
+
138
+
139
+ def bisync_initialized(project_name: str) -> bool:
140
+ """Check if bisync has been initialized for this project.
141
+
142
+ Args:
143
+ project_name: Name of the project
144
+
145
+ Returns:
146
+ True if bisync state exists, False otherwise
147
+ """
148
+ state_path = get_project_bisync_state(project_name)
149
+ return state_path.exists() and any(state_path.iterdir())
150
+
151
+
152
+ def get_project_remote(project: SyncProject, bucket_name: str) -> str:
153
+ """Build rclone remote path for project.
154
+
155
+ Args:
156
+ project: Project with cloud path
157
+ bucket_name: S3 bucket name
158
+
159
+ Returns:
160
+ Remote path like "basic-memory-cloud:bucket-name/basic-memory-llc"
161
+
162
+ Note:
163
+ The API returns paths like "/app/data/basic-memory-llc" because the S3 bucket
164
+ is mounted at /app/data on the fly machine. We need to strip the /app/data/
165
+ prefix to get the actual S3 path within the bucket.
166
+ """
167
+ # Normalize path to strip /app/data/ mount point prefix
168
+ cloud_path = normalize_project_path(project.path).lstrip("/")
169
+ return f"basic-memory-cloud:{bucket_name}/{cloud_path}"
170
+
171
+
172
+ def project_sync(
173
+ project: SyncProject,
174
+ bucket_name: str,
175
+ dry_run: bool = False,
176
+ verbose: bool = False,
177
+ *,
178
+ run: RunFunc = subprocess.run,
179
+ is_installed: IsInstalledFunc = is_rclone_installed,
180
+ filter_path: Path | None = None,
181
+ ) -> bool:
182
+ """One-way sync: local → cloud.
183
+
184
+ Makes cloud identical to local using rclone sync.
185
+
186
+ Args:
187
+ project: Project to sync
188
+ bucket_name: S3 bucket name
189
+ dry_run: Preview changes without applying
190
+ verbose: Show detailed output
191
+
192
+ Returns:
193
+ True if sync succeeded, False otherwise
194
+
195
+ Raises:
196
+ RcloneError: If project has no local_sync_path configured or rclone not installed
197
+ """
198
+ check_rclone_installed(is_installed=is_installed)
199
+
200
+ if not project.local_sync_path:
201
+ raise RcloneError(f"Project {project.name} has no local_sync_path configured")
202
+
203
+ local_path = Path(project.local_sync_path).expanduser()
204
+ remote_path = get_project_remote(project, bucket_name)
205
+ filter_path = filter_path or get_bmignore_filter_path()
206
+
207
+ cmd = [
208
+ "rclone",
209
+ "sync",
210
+ str(local_path),
211
+ remote_path,
212
+ "--filter-from",
213
+ str(filter_path),
214
+ ]
215
+
216
+ if verbose:
217
+ cmd.append("--verbose")
218
+ else:
219
+ cmd.append("--progress")
220
+
221
+ if dry_run:
222
+ cmd.append("--dry-run")
223
+
224
+ result = run(cmd, text=True)
225
+ return result.returncode == 0
226
+
227
+
228
+ def project_bisync(
229
+ project: SyncProject,
230
+ bucket_name: str,
231
+ dry_run: bool = False,
232
+ resync: bool = False,
233
+ verbose: bool = False,
234
+ *,
235
+ run: RunFunc = subprocess.run,
236
+ is_installed: IsInstalledFunc = is_rclone_installed,
237
+ version: tuple[int, int, int] | None = None,
238
+ filter_path: Path | None = None,
239
+ state_path: Path | None = None,
240
+ is_initialized: Callable[[str], bool] = bisync_initialized,
241
+ ) -> bool:
242
+ """Two-way sync: local ↔ cloud.
243
+
244
+ Uses rclone bisync with balanced defaults:
245
+ - conflict_resolve: newer (auto-resolve to most recent)
246
+ - max_delete: 25 (safety limit)
247
+ - compare: modtime (ignore size differences from line ending conversions)
248
+ - check_access: false (skip for performance)
249
+
250
+ Args:
251
+ project: Project to sync
252
+ bucket_name: S3 bucket name
253
+ dry_run: Preview changes without applying
254
+ resync: Force resync to establish new baseline
255
+ verbose: Show detailed output
256
+
257
+ Returns:
258
+ True if bisync succeeded, False otherwise
259
+
260
+ Raises:
261
+ RcloneError: If project has no local_sync_path, needs --resync, or rclone not installed
262
+ """
263
+ check_rclone_installed(is_installed=is_installed)
264
+
265
+ if not project.local_sync_path:
266
+ raise RcloneError(f"Project {project.name} has no local_sync_path configured")
267
+
268
+ local_path = Path(project.local_sync_path).expanduser()
269
+ remote_path = get_project_remote(project, bucket_name)
270
+ filter_path = filter_path or get_bmignore_filter_path()
271
+ state_path = state_path or get_project_bisync_state(project.name)
272
+
273
+ # Ensure state directory exists
274
+ state_path.mkdir(parents=True, exist_ok=True)
275
+
276
+ cmd = [
277
+ "rclone",
278
+ "bisync",
279
+ str(local_path),
280
+ remote_path,
281
+ "--resilient",
282
+ "--conflict-resolve=newer",
283
+ "--max-delete=25",
284
+ "--compare=modtime", # Ignore size differences from line ending conversions
285
+ "--filter-from",
286
+ str(filter_path),
287
+ "--workdir",
288
+ str(state_path),
289
+ ]
290
+
291
+ # Add --create-empty-src-dirs if rclone version supports it (v1.64+)
292
+ version = version if version is not None else get_rclone_version(run=run)
293
+ if supports_create_empty_src_dirs(version):
294
+ cmd.append("--create-empty-src-dirs")
295
+
296
+ if verbose:
297
+ cmd.append("--verbose")
298
+ else:
299
+ cmd.append("--progress")
300
+
301
+ if dry_run:
302
+ cmd.append("--dry-run")
303
+
304
+ if resync:
305
+ cmd.append("--resync")
306
+
307
+ # Check if first run requires resync
308
+ if not resync and not is_initialized(project.name) and not dry_run:
309
+ raise RcloneError(
310
+ f"First bisync for {project.name} requires --resync to establish baseline.\n"
311
+ f"Run: bm project bisync --name {project.name} --resync"
312
+ )
313
+
314
+ result = run(cmd, text=True)
315
+ return result.returncode == 0
316
+
317
+
318
+ def project_check(
319
+ project: SyncProject,
320
+ bucket_name: str,
321
+ one_way: bool = False,
322
+ *,
323
+ run: RunFunc = subprocess.run,
324
+ is_installed: IsInstalledFunc = is_rclone_installed,
325
+ filter_path: Path | None = None,
326
+ ) -> bool:
327
+ """Check integrity between local and cloud.
328
+
329
+ Verifies files match without transferring data.
330
+
331
+ Args:
332
+ project: Project to check
333
+ bucket_name: S3 bucket name
334
+ one_way: Only check for missing files on destination (faster)
335
+
336
+ Returns:
337
+ True if files match, False if differences found
338
+
339
+ Raises:
340
+ RcloneError: If project has no local_sync_path configured or rclone not installed
341
+ """
342
+ check_rclone_installed(is_installed=is_installed)
343
+
344
+ if not project.local_sync_path:
345
+ raise RcloneError(f"Project {project.name} has no local_sync_path configured")
346
+
347
+ local_path = Path(project.local_sync_path).expanduser()
348
+ remote_path = get_project_remote(project, bucket_name)
349
+ filter_path = filter_path or get_bmignore_filter_path()
350
+
351
+ cmd = [
352
+ "rclone",
353
+ "check",
354
+ str(local_path),
355
+ remote_path,
356
+ "--filter-from",
357
+ str(filter_path),
358
+ ]
359
+
360
+ if one_way:
361
+ cmd.append("--one-way")
362
+
363
+ result = run(cmd, capture_output=True, text=True)
364
+ return result.returncode == 0
365
+
366
+
367
+ def project_ls(
368
+ project: SyncProject,
369
+ bucket_name: str,
370
+ path: Optional[str] = None,
371
+ *,
372
+ run: RunFunc = subprocess.run,
373
+ is_installed: IsInstalledFunc = is_rclone_installed,
374
+ ) -> list[str]:
375
+ """List files in remote project.
376
+
377
+ Args:
378
+ project: Project to list files from
379
+ bucket_name: S3 bucket name
380
+ path: Optional subdirectory within project
381
+
382
+ Returns:
383
+ List of file paths
384
+
385
+ Raises:
386
+ subprocess.CalledProcessError: If rclone command fails
387
+ RcloneError: If rclone is not installed
388
+ """
389
+ check_rclone_installed(is_installed=is_installed)
390
+
391
+ remote_path = get_project_remote(project, bucket_name)
392
+ if path:
393
+ remote_path = f"{remote_path}/{path}"
394
+
395
+ cmd = ["rclone", "ls", remote_path]
396
+ result = run(cmd, capture_output=True, text=True, check=True)
397
+ return result.stdout.splitlines()
@@ -0,0 +1,110 @@
1
+ """rclone configuration management for Basic Memory Cloud.
2
+
3
+ This module provides simplified rclone configuration for SPEC-20.
4
+ Uses a single "basic-memory-cloud" remote for all operations.
5
+ """
6
+
7
+ import configparser
8
+ import os
9
+ import shutil
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from rich.console import Console
14
+
15
+ console = Console()
16
+
17
+
18
+ class RcloneConfigError(Exception):
19
+ """Exception raised for rclone configuration errors."""
20
+
21
+ pass
22
+
23
+
24
+ def get_rclone_config_path() -> Path:
25
+ """Get the path to rclone configuration file."""
26
+ config_dir = Path.home() / ".config" / "rclone"
27
+ config_dir.mkdir(parents=True, exist_ok=True)
28
+ return config_dir / "rclone.conf"
29
+
30
+
31
+ def backup_rclone_config() -> Optional[Path]:
32
+ """Create a backup of existing rclone config."""
33
+ config_path = get_rclone_config_path()
34
+ if not config_path.exists():
35
+ return None
36
+
37
+ backup_path = config_path.with_suffix(f".conf.backup-{os.getpid()}")
38
+ shutil.copy2(config_path, backup_path)
39
+ console.print(f"[dim]Created backup: {backup_path}[/dim]")
40
+ return backup_path
41
+
42
+
43
+ def load_rclone_config() -> configparser.ConfigParser:
44
+ """Load existing rclone configuration."""
45
+ config = configparser.ConfigParser()
46
+ config_path = get_rclone_config_path()
47
+
48
+ if config_path.exists():
49
+ config.read(config_path)
50
+
51
+ return config
52
+
53
+
54
+ def save_rclone_config(config: configparser.ConfigParser) -> None:
55
+ """Save rclone configuration to file."""
56
+ config_path = get_rclone_config_path()
57
+
58
+ with open(config_path, "w") as f:
59
+ config.write(f)
60
+
61
+ console.print(f"[dim]Updated rclone config: {config_path}[/dim]")
62
+
63
+
64
+ def configure_rclone_remote(
65
+ access_key: str,
66
+ secret_key: str,
67
+ endpoint: str = "https://fly.storage.tigris.dev",
68
+ region: str = "auto",
69
+ ) -> str:
70
+ """Configure single rclone remote named 'basic-memory-cloud'.
71
+
72
+ This is the simplified approach from SPEC-20 that uses one remote
73
+ for all Basic Memory cloud operations (not tenant-specific).
74
+
75
+ Args:
76
+ access_key: S3 access key ID
77
+ secret_key: S3 secret access key
78
+ endpoint: S3-compatible endpoint URL
79
+ region: S3 region (default: auto)
80
+
81
+ Returns:
82
+ The remote name: "basic-memory-cloud"
83
+ """
84
+ # Backup existing config
85
+ backup_rclone_config()
86
+
87
+ # Load existing config
88
+ config = load_rclone_config()
89
+
90
+ # Single remote name (not tenant-specific)
91
+ REMOTE_NAME = "basic-memory-cloud"
92
+
93
+ # Add/update the remote section
94
+ if not config.has_section(REMOTE_NAME):
95
+ config.add_section(REMOTE_NAME)
96
+
97
+ config.set(REMOTE_NAME, "type", "s3")
98
+ config.set(REMOTE_NAME, "provider", "Other")
99
+ config.set(REMOTE_NAME, "access_key_id", access_key)
100
+ config.set(REMOTE_NAME, "secret_access_key", secret_key)
101
+ config.set(REMOTE_NAME, "endpoint", endpoint)
102
+ config.set(REMOTE_NAME, "region", region)
103
+ # Prevent unnecessary encoding of filenames (only encode slashes and invalid UTF-8)
104
+ # This prevents files with spaces like "Hello World.md" from being quoted
105
+ config.set(REMOTE_NAME, "encoding", "Slash,InvalidUtf8")
106
+ # Save updated config
107
+ save_rclone_config(config)
108
+
109
+ console.print(f"[green]Configured rclone remote: {REMOTE_NAME}[/green]")
110
+ return REMOTE_NAME