basic-memory 0.7.0__py3-none-any.whl → 0.16.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 +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +27 -3
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/api/app.py +64 -18
- basic_memory/api/routers/__init__.py +4 -1
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +166 -21
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +9 -64
- basic_memory/api/routers/project_router.py +406 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +119 -4
- basic_memory/api/routers/search_router.py +5 -5
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +43 -9
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +13 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +51 -0
- basic_memory/cli/commands/db.py +28 -12
- basic_memory/cli/commands/import_chatgpt.py +40 -220
- basic_memory/cli/commands/import_claude_conversations.py +41 -168
- basic_memory/cli/commands/import_claude_projects.py +46 -157
- basic_memory/cli/commands/import_memory_json.py +48 -108
- basic_memory/cli/commands/mcp.py +84 -10
- basic_memory/cli/commands/project.py +876 -0
- basic_memory/cli/commands/status.py +50 -33
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +8 -7
- basic_memory/config.py +477 -23
- basic_memory/db.py +168 -17
- basic_memory/deps.py +251 -25
- basic_memory/file_utils.py +113 -58
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +177 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +143 -23
- basic_memory/markdown/markdown_processor.py +3 -3
- basic_memory/markdown/plugins.py +39 -21
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +28 -13
- basic_memory/mcp/async_client.py +134 -4
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +7 -13
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +130 -0
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +225 -0
- basic_memory/mcp/tools/edit_note.py +320 -0
- basic_memory/mcp/tools/list_directory.py +167 -0
- basic_memory/mcp/tools/move_note.py +545 -0
- basic_memory/mcp/tools/project_management.py +200 -0
- basic_memory/mcp/tools/read_content.py +271 -0
- basic_memory/mcp/tools/read_note.py +255 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +369 -23
- basic_memory/mcp/tools/utils.py +374 -16
- basic_memory/mcp/tools/view_note.py +77 -0
- basic_memory/mcp/tools/write_note.py +207 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +67 -15
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +10 -6
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +229 -7
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +103 -0
- basic_memory/repository/relation_repository.py +21 -2
- basic_memory/repository/repository.py +147 -29
- basic_memory/repository/search_repository.py +411 -62
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +97 -8
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +187 -25
- basic_memory/schemas/project_info.py +211 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/response.py +1 -1
- basic_memory/schemas/search.py +31 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +241 -104
- basic_memory/services/directory_service.py +295 -0
- basic_memory/services/entity_service.py +590 -60
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +284 -30
- basic_memory/services/initialization.py +191 -0
- basic_memory/services/link_resolver.py +49 -56
- basic_memory/services/project_service.py +863 -0
- basic_memory/services/search_service.py +168 -32
- basic_memory/sync/__init__.py +3 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1180 -109
- basic_memory/sync/watch_service.py +412 -135
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +383 -51
- basic_memory-0.16.1.dist-info/METADATA +493 -0
- basic_memory-0.16.1.dist-info/RECORD +148 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -206
- basic_memory/cli/commands/tools.py +0 -157
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -170
- basic_memory/mcp/tools/notes.py +0 -202
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -31
- basic_memory-0.7.0.dist-info/METADATA +0 -378
- basic_memory-0.7.0.dist-info/RECORD +0 -82
- {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.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,301 @@
|
|
|
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 subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
|
|
19
|
+
from basic_memory.utils import normalize_project_path
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RcloneError(Exception):
|
|
25
|
+
"""Exception raised for rclone command errors."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SyncProject:
|
|
32
|
+
"""Project configured for cloud sync.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
name: Project name
|
|
36
|
+
path: Cloud path (e.g., "app/data/research")
|
|
37
|
+
local_sync_path: Local directory for syncing (optional)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
path: str
|
|
42
|
+
local_sync_path: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_bmignore_filter_path() -> Path:
|
|
46
|
+
"""Get path to rclone filter file.
|
|
47
|
+
|
|
48
|
+
Uses ~/.basic-memory/.bmignore converted to rclone format.
|
|
49
|
+
File is automatically created with default patterns on first use.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Path to rclone filter file
|
|
53
|
+
"""
|
|
54
|
+
# Import here to avoid circular dependency
|
|
55
|
+
from basic_memory.cli.commands.cloud.bisync_commands import (
|
|
56
|
+
convert_bmignore_to_rclone_filters,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return convert_bmignore_to_rclone_filters()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_project_bisync_state(project_name: str) -> Path:
|
|
63
|
+
"""Get path to project's bisync state directory.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
project_name: Name of the project
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Path to bisync state directory for this project
|
|
70
|
+
"""
|
|
71
|
+
return Path.home() / ".basic-memory" / "bisync-state" / project_name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def bisync_initialized(project_name: str) -> bool:
|
|
75
|
+
"""Check if bisync has been initialized for this project.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
project_name: Name of the project
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
True if bisync state exists, False otherwise
|
|
82
|
+
"""
|
|
83
|
+
state_path = get_project_bisync_state(project_name)
|
|
84
|
+
return state_path.exists() and any(state_path.iterdir())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_project_remote(project: SyncProject, bucket_name: str) -> str:
|
|
88
|
+
"""Build rclone remote path for project.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
project: Project with cloud path
|
|
92
|
+
bucket_name: S3 bucket name
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Remote path like "basic-memory-cloud:bucket-name/basic-memory-llc"
|
|
96
|
+
|
|
97
|
+
Note:
|
|
98
|
+
The API returns paths like "/app/data/basic-memory-llc" because the S3 bucket
|
|
99
|
+
is mounted at /app/data on the fly machine. We need to strip the /app/data/
|
|
100
|
+
prefix to get the actual S3 path within the bucket.
|
|
101
|
+
"""
|
|
102
|
+
# Normalize path to strip /app/data/ mount point prefix
|
|
103
|
+
cloud_path = normalize_project_path(project.path).lstrip("/")
|
|
104
|
+
return f"basic-memory-cloud:{bucket_name}/{cloud_path}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def project_sync(
|
|
108
|
+
project: SyncProject,
|
|
109
|
+
bucket_name: str,
|
|
110
|
+
dry_run: bool = False,
|
|
111
|
+
verbose: bool = False,
|
|
112
|
+
) -> bool:
|
|
113
|
+
"""One-way sync: local → cloud.
|
|
114
|
+
|
|
115
|
+
Makes cloud identical to local using rclone sync.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
project: Project to sync
|
|
119
|
+
bucket_name: S3 bucket name
|
|
120
|
+
dry_run: Preview changes without applying
|
|
121
|
+
verbose: Show detailed output
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if sync succeeded, False otherwise
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
RcloneError: If project has no local_sync_path configured
|
|
128
|
+
"""
|
|
129
|
+
if not project.local_sync_path:
|
|
130
|
+
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
|
131
|
+
|
|
132
|
+
local_path = Path(project.local_sync_path).expanduser()
|
|
133
|
+
remote_path = get_project_remote(project, bucket_name)
|
|
134
|
+
filter_path = get_bmignore_filter_path()
|
|
135
|
+
|
|
136
|
+
cmd = [
|
|
137
|
+
"rclone",
|
|
138
|
+
"sync",
|
|
139
|
+
str(local_path),
|
|
140
|
+
remote_path,
|
|
141
|
+
"--filter-from",
|
|
142
|
+
str(filter_path),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
if verbose:
|
|
146
|
+
cmd.append("--verbose")
|
|
147
|
+
else:
|
|
148
|
+
cmd.append("--progress")
|
|
149
|
+
|
|
150
|
+
if dry_run:
|
|
151
|
+
cmd.append("--dry-run")
|
|
152
|
+
|
|
153
|
+
result = subprocess.run(cmd, text=True)
|
|
154
|
+
return result.returncode == 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def project_bisync(
|
|
158
|
+
project: SyncProject,
|
|
159
|
+
bucket_name: str,
|
|
160
|
+
dry_run: bool = False,
|
|
161
|
+
resync: bool = False,
|
|
162
|
+
verbose: bool = False,
|
|
163
|
+
) -> bool:
|
|
164
|
+
"""Two-way sync: local ↔ cloud.
|
|
165
|
+
|
|
166
|
+
Uses rclone bisync with balanced defaults:
|
|
167
|
+
- conflict_resolve: newer (auto-resolve to most recent)
|
|
168
|
+
- max_delete: 25 (safety limit)
|
|
169
|
+
- compare: modtime (ignore size differences from line ending conversions)
|
|
170
|
+
- check_access: false (skip for performance)
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
project: Project to sync
|
|
174
|
+
bucket_name: S3 bucket name
|
|
175
|
+
dry_run: Preview changes without applying
|
|
176
|
+
resync: Force resync to establish new baseline
|
|
177
|
+
verbose: Show detailed output
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if bisync succeeded, False otherwise
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
RcloneError: If project has no local_sync_path or needs --resync
|
|
184
|
+
"""
|
|
185
|
+
if not project.local_sync_path:
|
|
186
|
+
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
|
187
|
+
|
|
188
|
+
local_path = Path(project.local_sync_path).expanduser()
|
|
189
|
+
remote_path = get_project_remote(project, bucket_name)
|
|
190
|
+
filter_path = get_bmignore_filter_path()
|
|
191
|
+
state_path = get_project_bisync_state(project.name)
|
|
192
|
+
|
|
193
|
+
# Ensure state directory exists
|
|
194
|
+
state_path.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
|
|
196
|
+
cmd = [
|
|
197
|
+
"rclone",
|
|
198
|
+
"bisync",
|
|
199
|
+
str(local_path),
|
|
200
|
+
remote_path,
|
|
201
|
+
"--create-empty-src-dirs",
|
|
202
|
+
"--resilient",
|
|
203
|
+
"--conflict-resolve=newer",
|
|
204
|
+
"--max-delete=25",
|
|
205
|
+
"--compare=modtime", # Ignore size differences from line ending conversions
|
|
206
|
+
"--filter-from",
|
|
207
|
+
str(filter_path),
|
|
208
|
+
"--workdir",
|
|
209
|
+
str(state_path),
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
if verbose:
|
|
213
|
+
cmd.append("--verbose")
|
|
214
|
+
else:
|
|
215
|
+
cmd.append("--progress")
|
|
216
|
+
|
|
217
|
+
if dry_run:
|
|
218
|
+
cmd.append("--dry-run")
|
|
219
|
+
|
|
220
|
+
if resync:
|
|
221
|
+
cmd.append("--resync")
|
|
222
|
+
|
|
223
|
+
# Check if first run requires resync
|
|
224
|
+
if not resync and not bisync_initialized(project.name) and not dry_run:
|
|
225
|
+
raise RcloneError(
|
|
226
|
+
f"First bisync for {project.name} requires --resync to establish baseline.\n"
|
|
227
|
+
f"Run: bm project bisync --name {project.name} --resync"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
result = subprocess.run(cmd, text=True)
|
|
231
|
+
return result.returncode == 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def project_check(
|
|
235
|
+
project: SyncProject,
|
|
236
|
+
bucket_name: str,
|
|
237
|
+
one_way: bool = False,
|
|
238
|
+
) -> bool:
|
|
239
|
+
"""Check integrity between local and cloud.
|
|
240
|
+
|
|
241
|
+
Verifies files match without transferring data.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
project: Project to check
|
|
245
|
+
bucket_name: S3 bucket name
|
|
246
|
+
one_way: Only check for missing files on destination (faster)
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
True if files match, False if differences found
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
RcloneError: If project has no local_sync_path configured
|
|
253
|
+
"""
|
|
254
|
+
if not project.local_sync_path:
|
|
255
|
+
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
|
256
|
+
|
|
257
|
+
local_path = Path(project.local_sync_path).expanduser()
|
|
258
|
+
remote_path = get_project_remote(project, bucket_name)
|
|
259
|
+
filter_path = get_bmignore_filter_path()
|
|
260
|
+
|
|
261
|
+
cmd = [
|
|
262
|
+
"rclone",
|
|
263
|
+
"check",
|
|
264
|
+
str(local_path),
|
|
265
|
+
remote_path,
|
|
266
|
+
"--filter-from",
|
|
267
|
+
str(filter_path),
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
if one_way:
|
|
271
|
+
cmd.append("--one-way")
|
|
272
|
+
|
|
273
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
274
|
+
return result.returncode == 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def project_ls(
|
|
278
|
+
project: SyncProject,
|
|
279
|
+
bucket_name: str,
|
|
280
|
+
path: Optional[str] = None,
|
|
281
|
+
) -> list[str]:
|
|
282
|
+
"""List files in remote project.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
project: Project to list files from
|
|
286
|
+
bucket_name: S3 bucket name
|
|
287
|
+
path: Optional subdirectory within project
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
List of file paths
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
subprocess.CalledProcessError: If rclone command fails
|
|
294
|
+
"""
|
|
295
|
+
remote_path = get_project_remote(project, bucket_name)
|
|
296
|
+
if path:
|
|
297
|
+
remote_path = f"{remote_path}/{path}"
|
|
298
|
+
|
|
299
|
+
cmd = ["rclone", "ls", remote_path]
|
|
300
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
301
|
+
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
|