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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
- basic_memory/api/app.py +10 -4
- basic_memory/api/routers/directory_router.py +23 -2
- basic_memory/api/routers/knowledge_router.py +25 -8
- basic_memory/api/routers/project_router.py +100 -4
- basic_memory/cli/app.py +9 -28
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/cloud/__init__.py +5 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
- basic_memory/cli/commands/cloud/core_commands.py +288 -0
- basic_memory/cli/commands/cloud/mount_commands.py +295 -0
- basic_memory/cli/commands/cloud/rclone_config.py +288 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
- basic_memory/cli/commands/command_utils.py +43 -0
- basic_memory/cli/commands/import_memory_json.py +0 -4
- basic_memory/cli/commands/mcp.py +77 -60
- basic_memory/cli/commands/project.py +154 -152
- basic_memory/cli/commands/status.py +25 -22
- basic_memory/cli/commands/sync.py +45 -228
- basic_memory/cli/commands/tool.py +87 -16
- basic_memory/cli/main.py +1 -0
- basic_memory/config.py +131 -21
- basic_memory/db.py +104 -3
- basic_memory/deps.py +27 -8
- basic_memory/file_utils.py +37 -13
- basic_memory/ignore_utils.py +295 -0
- basic_memory/markdown/plugins.py +9 -7
- basic_memory/mcp/async_client.py +124 -14
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
- basic_memory/mcp/prompts/continue_conversation.py +17 -16
- basic_memory/mcp/prompts/recent_activity.py +116 -32
- basic_memory/mcp/prompts/search.py +13 -12
- basic_memory/mcp/prompts/utils.py +11 -4
- basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
- basic_memory/mcp/resources/project_info.py +27 -11
- basic_memory/mcp/server.py +0 -37
- basic_memory/mcp/tools/__init__.py +5 -6
- basic_memory/mcp/tools/build_context.py +67 -56
- basic_memory/mcp/tools/canvas.py +38 -26
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +81 -47
- basic_memory/mcp/tools/edit_note.py +155 -138
- basic_memory/mcp/tools/list_directory.py +112 -99
- basic_memory/mcp/tools/move_note.py +181 -101
- basic_memory/mcp/tools/project_management.py +113 -277
- basic_memory/mcp/tools/read_content.py +91 -74
- basic_memory/mcp/tools/read_note.py +152 -115
- basic_memory/mcp/tools/recent_activity.py +471 -68
- basic_memory/mcp/tools/search.py +105 -92
- basic_memory/mcp/tools/sync_status.py +136 -130
- basic_memory/mcp/tools/utils.py +4 -0
- basic_memory/mcp/tools/view_note.py +44 -33
- basic_memory/mcp/tools/write_note.py +151 -90
- basic_memory/models/knowledge.py +12 -6
- basic_memory/models/project.py +6 -2
- basic_memory/repository/entity_repository.py +89 -82
- basic_memory/repository/relation_repository.py +13 -0
- basic_memory/repository/repository.py +18 -5
- basic_memory/repository/search_repository.py +46 -2
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +39 -11
- basic_memory/schemas/cloud.py +46 -0
- basic_memory/schemas/memory.py +90 -21
- basic_memory/schemas/project_info.py +9 -10
- basic_memory/schemas/sync_report.py +48 -0
- basic_memory/services/context_service.py +25 -11
- basic_memory/services/directory_service.py +124 -3
- basic_memory/services/entity_service.py +100 -48
- basic_memory/services/initialization.py +30 -11
- basic_memory/services/project_service.py +101 -24
- basic_memory/services/search_service.py +16 -8
- basic_memory/sync/sync_service.py +173 -34
- basic_memory/sync/watch_service.py +101 -40
- basic_memory/utils.py +14 -4
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
- basic_memory-0.15.1.dist-info/RECORD +146 -0
- basic_memory/mcp/project_session.py +0 -120
- basic_memory-0.14.4.dist-info/RECORD +0 -133
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
- {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
|