basic-memory 0.16.1__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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/env.py +112 -26
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +45 -24
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/knowledge_router.py +17 -5
- basic_memory/api/routers/project_router.py +68 -14
- basic_memory/api/routers/resource_router.py +37 -27
- basic_memory/api/routers/utils.py +53 -14
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +43 -7
- basic_memory/cli/auth.py +27 -4
- basic_memory/cli/commands/__init__.py +3 -1
- basic_memory/cli/commands/cloud/api_client.py +20 -5
- basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
- basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
- basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
- basic_memory/cli/commands/cloud/upload.py +10 -3
- basic_memory/cli/commands/command_utils.py +52 -4
- basic_memory/cli/commands/db.py +78 -19
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +12 -8
- basic_memory/cli/commands/import_claude_conversations.py +12 -8
- basic_memory/cli/commands/import_claude_projects.py +12 -8
- basic_memory/cli/commands/import_memory_json.py +12 -8
- basic_memory/cli/commands/mcp.py +8 -26
- basic_memory/cli/commands/project.py +22 -9
- basic_memory/cli/commands/status.py +3 -2
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +7 -0
- basic_memory/config.py +177 -77
- basic_memory/db.py +183 -77
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +14 -409
- basic_memory/file_utils.py +212 -3
- basic_memory/ignore_utils.py +5 -5
- basic_memory/importers/base.py +40 -19
- basic_memory/importers/chatgpt_importer.py +17 -4
- basic_memory/importers/claude_conversations_importer.py +27 -12
- basic_memory/importers/claude_projects_importer.py +50 -14
- basic_memory/importers/memory_json_importer.py +36 -16
- basic_memory/importers/utils.py +5 -2
- basic_memory/markdown/entity_parser.py +62 -23
- basic_memory/markdown/markdown_processor.py +67 -4
- basic_memory/markdown/plugins.py +4 -2
- basic_memory/markdown/utils.py +10 -1
- basic_memory/mcp/async_client.py +1 -0
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +47 -33
- basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
- basic_memory/mcp/prompts/recent_activity.py +2 -2
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/server.py +58 -0
- basic_memory/mcp/tools/build_context.py +14 -14
- basic_memory/mcp/tools/canvas.py +34 -12
- basic_memory/mcp/tools/chatgpt_tools.py +4 -1
- basic_memory/mcp/tools/delete_note.py +31 -7
- basic_memory/mcp/tools/edit_note.py +14 -9
- basic_memory/mcp/tools/list_directory.py +7 -17
- basic_memory/mcp/tools/move_note.py +35 -31
- basic_memory/mcp/tools/project_management.py +29 -25
- basic_memory/mcp/tools/read_content.py +13 -3
- basic_memory/mcp/tools/read_note.py +24 -14
- basic_memory/mcp/tools/recent_activity.py +32 -38
- basic_memory/mcp/tools/search.py +17 -10
- basic_memory/mcp/tools/utils.py +28 -0
- basic_memory/mcp/tools/view_note.py +2 -1
- basic_memory/mcp/tools/write_note.py +37 -14
- basic_memory/models/knowledge.py +15 -2
- basic_memory/models/project.py +7 -1
- basic_memory/models/search.py +58 -2
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/entity_repository.py +210 -3
- basic_memory/repository/observation_repository.py +1 -0
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_repository.py +38 -1
- basic_memory/repository/relation_repository.py +58 -2
- basic_memory/repository/repository.py +1 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +77 -615
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/base.py +36 -6
- basic_memory/schemas/directory.py +2 -1
- basic_memory/schemas/memory.py +9 -2
- basic_memory/schemas/project_info.py +2 -0
- basic_memory/schemas/response.py +84 -27
- basic_memory/schemas/search.py +5 -0
- basic_memory/schemas/sync_report.py +1 -1
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/context_service.py +219 -43
- basic_memory/services/directory_service.py +26 -11
- basic_memory/services/entity_service.py +68 -33
- basic_memory/services/file_service.py +131 -16
- basic_memory/services/initialization.py +51 -26
- basic_memory/services/link_resolver.py +1 -0
- basic_memory/services/project_service.py +68 -43
- basic_memory/services/search_service.py +75 -16
- basic_memory/sync/__init__.py +2 -1
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +135 -115
- basic_memory/sync/watch_service.py +32 -12
- basic_memory/telemetry.py +249 -0
- basic_memory/utils.py +96 -75
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- basic_memory-0.16.1.dist-info/RECORD +0 -148
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/cli/auth.py
CHANGED
|
@@ -7,6 +7,9 @@ import os
|
|
|
7
7
|
import secrets
|
|
8
8
|
import time
|
|
9
9
|
import webbrowser
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from collections.abc import AsyncIterator, Callable
|
|
12
|
+
from typing import AsyncContextManager
|
|
10
13
|
|
|
11
14
|
import httpx
|
|
12
15
|
from rich.console import Console
|
|
@@ -19,7 +22,12 @@ console = Console()
|
|
|
19
22
|
class CLIAuth:
|
|
20
23
|
"""Handles WorkOS OAuth Device Authorization for CLI tools."""
|
|
21
24
|
|
|
22
|
-
def __init__(
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
client_id: str,
|
|
28
|
+
authkit_domain: str,
|
|
29
|
+
http_client_factory: Callable[[], AsyncContextManager[httpx.AsyncClient]] | None = None,
|
|
30
|
+
):
|
|
23
31
|
self.client_id = client_id
|
|
24
32
|
self.authkit_domain = authkit_domain
|
|
25
33
|
app_config = ConfigManager().config
|
|
@@ -28,6 +36,21 @@ class CLIAuth:
|
|
|
28
36
|
# PKCE parameters
|
|
29
37
|
self.code_verifier = None
|
|
30
38
|
self.code_challenge = None
|
|
39
|
+
self._http_client_factory = http_client_factory
|
|
40
|
+
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def _get_http_client(self) -> AsyncIterator[httpx.AsyncClient]:
|
|
43
|
+
"""Create an AsyncClient, optionally via injected factory.
|
|
44
|
+
|
|
45
|
+
Why: enables reliable tests without monkeypatching httpx internals while
|
|
46
|
+
still using real httpx request/response objects.
|
|
47
|
+
"""
|
|
48
|
+
if self._http_client_factory:
|
|
49
|
+
async with self._http_client_factory() as client:
|
|
50
|
+
yield client
|
|
51
|
+
else:
|
|
52
|
+
async with httpx.AsyncClient() as client:
|
|
53
|
+
yield client
|
|
31
54
|
|
|
32
55
|
def generate_pkce_pair(self) -> tuple[str, str]:
|
|
33
56
|
"""Generate PKCE code verifier and challenge."""
|
|
@@ -57,7 +80,7 @@ class CLIAuth:
|
|
|
57
80
|
}
|
|
58
81
|
|
|
59
82
|
try:
|
|
60
|
-
async with
|
|
83
|
+
async with self._get_http_client() as client:
|
|
61
84
|
response = await client.post(device_auth_url, data=data)
|
|
62
85
|
|
|
63
86
|
if response.status_code == 200:
|
|
@@ -111,7 +134,7 @@ class CLIAuth:
|
|
|
111
134
|
|
|
112
135
|
for _attempt in range(max_attempts):
|
|
113
136
|
try:
|
|
114
|
-
async with
|
|
137
|
+
async with self._get_http_client() as client:
|
|
115
138
|
response = await client.post(token_url, data=data)
|
|
116
139
|
|
|
117
140
|
if response.status_code == 200:
|
|
@@ -201,7 +224,7 @@ class CLIAuth:
|
|
|
201
224
|
}
|
|
202
225
|
|
|
203
226
|
try:
|
|
204
|
-
async with
|
|
227
|
+
async with self._get_http_client() as client:
|
|
205
228
|
response = await client.post(token_url, data=data)
|
|
206
229
|
|
|
207
230
|
if response.status_code == 200:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""CLI commands for basic-memory."""
|
|
2
2
|
|
|
3
3
|
from . import status, db, import_memory_json, mcp, import_claude_conversations
|
|
4
|
-
from . import import_claude_projects, import_chatgpt, tool, project
|
|
4
|
+
from . import import_claude_projects, import_chatgpt, tool, project, format, telemetry
|
|
5
5
|
|
|
6
6
|
__all__ = [
|
|
7
7
|
"status",
|
|
@@ -13,4 +13,6 @@ __all__ = [
|
|
|
13
13
|
"import_chatgpt",
|
|
14
14
|
"tool",
|
|
15
15
|
"project",
|
|
16
|
+
"format",
|
|
17
|
+
"telemetry",
|
|
16
18
|
]
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"""Cloud API client utilities."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
3
4
|
from typing import Optional
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import AsyncContextManager, Callable
|
|
4
7
|
|
|
5
8
|
import httpx
|
|
6
9
|
import typer
|
|
@@ -11,6 +14,8 @@ from basic_memory.config import ConfigManager
|
|
|
11
14
|
|
|
12
15
|
console = Console()
|
|
13
16
|
|
|
17
|
+
HttpClientFactory = Callable[[], AsyncContextManager[httpx.AsyncClient]]
|
|
18
|
+
|
|
14
19
|
|
|
15
20
|
class CloudAPIError(Exception):
|
|
16
21
|
"""Exception raised for cloud API errors."""
|
|
@@ -38,14 +43,14 @@ def get_cloud_config() -> tuple[str, str, str]:
|
|
|
38
43
|
return config.cloud_client_id, config.cloud_domain, config.cloud_host
|
|
39
44
|
|
|
40
45
|
|
|
41
|
-
async def get_authenticated_headers() -> dict[str, str]:
|
|
46
|
+
async def get_authenticated_headers(auth: CLIAuth | None = None) -> dict[str, str]:
|
|
42
47
|
"""
|
|
43
48
|
Get authentication headers with JWT token.
|
|
44
49
|
handles jwt refresh if needed.
|
|
45
50
|
"""
|
|
46
51
|
client_id, domain, _ = get_cloud_config()
|
|
47
|
-
|
|
48
|
-
token = await
|
|
52
|
+
auth_obj = auth or CLIAuth(client_id=client_id, authkit_domain=domain)
|
|
53
|
+
token = await auth_obj.get_valid_token()
|
|
49
54
|
if not token:
|
|
50
55
|
console.print("[red]Not authenticated. Please run 'basic-memory cloud login' first.[/red]")
|
|
51
56
|
raise typer.Exit(1)
|
|
@@ -53,21 +58,31 @@ async def get_authenticated_headers() -> dict[str, str]:
|
|
|
53
58
|
return {"Authorization": f"Bearer {token}"}
|
|
54
59
|
|
|
55
60
|
|
|
61
|
+
@asynccontextmanager
|
|
62
|
+
async def _default_http_client(timeout: float) -> AsyncIterator[httpx.AsyncClient]:
|
|
63
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
64
|
+
yield client
|
|
65
|
+
|
|
66
|
+
|
|
56
67
|
async def make_api_request(
|
|
57
68
|
method: str,
|
|
58
69
|
url: str,
|
|
59
70
|
headers: Optional[dict] = None,
|
|
60
71
|
json_data: Optional[dict] = None,
|
|
61
72
|
timeout: float = 30.0,
|
|
73
|
+
*,
|
|
74
|
+
auth: CLIAuth | None = None,
|
|
75
|
+
http_client_factory: HttpClientFactory | None = None,
|
|
62
76
|
) -> httpx.Response:
|
|
63
77
|
"""Make an API request to the cloud service."""
|
|
64
78
|
headers = headers or {}
|
|
65
|
-
auth_headers = await get_authenticated_headers()
|
|
79
|
+
auth_headers = await get_authenticated_headers(auth=auth)
|
|
66
80
|
headers.update(auth_headers)
|
|
67
81
|
# Add debug headers to help with compression issues
|
|
68
82
|
headers.setdefault("Accept-Encoding", "identity") # Disable compression for debugging
|
|
69
83
|
|
|
70
|
-
|
|
84
|
+
client_factory = http_client_factory or (lambda: _default_http_client(timeout))
|
|
85
|
+
async with client_factory() as client:
|
|
71
86
|
try:
|
|
72
87
|
response = await client.request(method=method, url=url, headers=headers, json=json_data)
|
|
73
88
|
response.raise_for_status()
|
|
@@ -16,7 +16,10 @@ class CloudUtilsError(Exception):
|
|
|
16
16
|
pass
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
async def fetch_cloud_projects(
|
|
19
|
+
async def fetch_cloud_projects(
|
|
20
|
+
*,
|
|
21
|
+
api_request=make_api_request,
|
|
22
|
+
) -> CloudProjectList:
|
|
20
23
|
"""Fetch list of projects from cloud API.
|
|
21
24
|
|
|
22
25
|
Returns:
|
|
@@ -27,14 +30,18 @@ async def fetch_cloud_projects() -> CloudProjectList:
|
|
|
27
30
|
config = config_manager.config
|
|
28
31
|
host_url = config.cloud_host.rstrip("/")
|
|
29
32
|
|
|
30
|
-
response = await
|
|
33
|
+
response = await api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
|
|
31
34
|
|
|
32
35
|
return CloudProjectList.model_validate(response.json())
|
|
33
36
|
except Exception as e:
|
|
34
37
|
raise CloudUtilsError(f"Failed to fetch cloud projects: {e}") from e
|
|
35
38
|
|
|
36
39
|
|
|
37
|
-
async def create_cloud_project(
|
|
40
|
+
async def create_cloud_project(
|
|
41
|
+
project_name: str,
|
|
42
|
+
*,
|
|
43
|
+
api_request=make_api_request,
|
|
44
|
+
) -> CloudProjectCreateResponse:
|
|
38
45
|
"""Create a new project on cloud.
|
|
39
46
|
|
|
40
47
|
Args:
|
|
@@ -57,7 +64,7 @@ async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
|
|
|
57
64
|
set_default=False,
|
|
58
65
|
)
|
|
59
66
|
|
|
60
|
-
response = await
|
|
67
|
+
response = await api_request(
|
|
61
68
|
method="POST",
|
|
62
69
|
url=f"{host_url}/proxy/projects/projects",
|
|
63
70
|
headers={"Content-Type": "application/json"},
|
|
@@ -84,7 +91,7 @@ async def sync_project(project_name: str, force_full: bool = False) -> None:
|
|
|
84
91
|
raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
|
|
85
92
|
|
|
86
93
|
|
|
87
|
-
async def project_exists(project_name: str) -> bool:
|
|
94
|
+
async def project_exists(project_name: str, *, api_request=make_api_request) -> bool:
|
|
88
95
|
"""Check if a project exists on cloud.
|
|
89
96
|
|
|
90
97
|
Args:
|
|
@@ -94,7 +101,7 @@ async def project_exists(project_name: str) -> bool:
|
|
|
94
101
|
True if project exists, False otherwise
|
|
95
102
|
"""
|
|
96
103
|
try:
|
|
97
|
-
projects = await fetch_cloud_projects()
|
|
104
|
+
projects = await fetch_cloud_projects(api_request=api_request)
|
|
98
105
|
project_names = {p.name for p in projects.projects}
|
|
99
106
|
return project_name in project_names
|
|
100
107
|
except Exception:
|
|
@@ -9,17 +9,32 @@ This module provides simplified, project-scoped rclone operations:
|
|
|
9
9
|
Replaces tenant-wide sync with project-scoped workflows.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import re
|
|
12
13
|
import subprocess
|
|
13
14
|
from dataclasses import dataclass
|
|
15
|
+
from functools import lru_cache
|
|
14
16
|
from pathlib import Path
|
|
15
|
-
from typing import Optional
|
|
17
|
+
from typing import Callable, Optional, Protocol
|
|
16
18
|
|
|
19
|
+
from loguru import logger
|
|
17
20
|
from rich.console import Console
|
|
18
21
|
|
|
22
|
+
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
|
|
19
23
|
from basic_memory.utils import normalize_project_path
|
|
20
24
|
|
|
21
25
|
console = Console()
|
|
22
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
|
+
|
|
23
38
|
|
|
24
39
|
class RcloneError(Exception):
|
|
25
40
|
"""Exception raised for rclone command errors."""
|
|
@@ -27,6 +42,56 @@ class RcloneError(Exception):
|
|
|
27
42
|
pass
|
|
28
43
|
|
|
29
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
|
+
|
|
30
95
|
@dataclass
|
|
31
96
|
class SyncProject:
|
|
32
97
|
"""Project configured for cloud sync.
|
|
@@ -109,6 +174,10 @@ def project_sync(
|
|
|
109
174
|
bucket_name: str,
|
|
110
175
|
dry_run: bool = False,
|
|
111
176
|
verbose: bool = False,
|
|
177
|
+
*,
|
|
178
|
+
run: RunFunc = subprocess.run,
|
|
179
|
+
is_installed: IsInstalledFunc = is_rclone_installed,
|
|
180
|
+
filter_path: Path | None = None,
|
|
112
181
|
) -> bool:
|
|
113
182
|
"""One-way sync: local → cloud.
|
|
114
183
|
|
|
@@ -124,14 +193,16 @@ def project_sync(
|
|
|
124
193
|
True if sync succeeded, False otherwise
|
|
125
194
|
|
|
126
195
|
Raises:
|
|
127
|
-
RcloneError: If project has no local_sync_path configured
|
|
196
|
+
RcloneError: If project has no local_sync_path configured or rclone not installed
|
|
128
197
|
"""
|
|
198
|
+
check_rclone_installed(is_installed=is_installed)
|
|
199
|
+
|
|
129
200
|
if not project.local_sync_path:
|
|
130
201
|
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
|
131
202
|
|
|
132
203
|
local_path = Path(project.local_sync_path).expanduser()
|
|
133
204
|
remote_path = get_project_remote(project, bucket_name)
|
|
134
|
-
filter_path = get_bmignore_filter_path()
|
|
205
|
+
filter_path = filter_path or get_bmignore_filter_path()
|
|
135
206
|
|
|
136
207
|
cmd = [
|
|
137
208
|
"rclone",
|
|
@@ -150,7 +221,7 @@ def project_sync(
|
|
|
150
221
|
if dry_run:
|
|
151
222
|
cmd.append("--dry-run")
|
|
152
223
|
|
|
153
|
-
result =
|
|
224
|
+
result = run(cmd, text=True)
|
|
154
225
|
return result.returncode == 0
|
|
155
226
|
|
|
156
227
|
|
|
@@ -160,6 +231,13 @@ def project_bisync(
|
|
|
160
231
|
dry_run: bool = False,
|
|
161
232
|
resync: bool = False,
|
|
162
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,
|
|
163
241
|
) -> bool:
|
|
164
242
|
"""Two-way sync: local ↔ cloud.
|
|
165
243
|
|
|
@@ -180,15 +258,17 @@ def project_bisync(
|
|
|
180
258
|
True if bisync succeeded, False otherwise
|
|
181
259
|
|
|
182
260
|
Raises:
|
|
183
|
-
RcloneError: If project has no local_sync_path
|
|
261
|
+
RcloneError: If project has no local_sync_path, needs --resync, or rclone not installed
|
|
184
262
|
"""
|
|
263
|
+
check_rclone_installed(is_installed=is_installed)
|
|
264
|
+
|
|
185
265
|
if not project.local_sync_path:
|
|
186
266
|
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
|
187
267
|
|
|
188
268
|
local_path = Path(project.local_sync_path).expanduser()
|
|
189
269
|
remote_path = get_project_remote(project, bucket_name)
|
|
190
|
-
filter_path = get_bmignore_filter_path()
|
|
191
|
-
state_path = get_project_bisync_state(project.name)
|
|
270
|
+
filter_path = filter_path or get_bmignore_filter_path()
|
|
271
|
+
state_path = state_path or get_project_bisync_state(project.name)
|
|
192
272
|
|
|
193
273
|
# Ensure state directory exists
|
|
194
274
|
state_path.mkdir(parents=True, exist_ok=True)
|
|
@@ -198,7 +278,6 @@ def project_bisync(
|
|
|
198
278
|
"bisync",
|
|
199
279
|
str(local_path),
|
|
200
280
|
remote_path,
|
|
201
|
-
"--create-empty-src-dirs",
|
|
202
281
|
"--resilient",
|
|
203
282
|
"--conflict-resolve=newer",
|
|
204
283
|
"--max-delete=25",
|
|
@@ -209,6 +288,11 @@ def project_bisync(
|
|
|
209
288
|
str(state_path),
|
|
210
289
|
]
|
|
211
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
|
+
|
|
212
296
|
if verbose:
|
|
213
297
|
cmd.append("--verbose")
|
|
214
298
|
else:
|
|
@@ -221,13 +305,13 @@ def project_bisync(
|
|
|
221
305
|
cmd.append("--resync")
|
|
222
306
|
|
|
223
307
|
# Check if first run requires resync
|
|
224
|
-
if not resync and not
|
|
308
|
+
if not resync and not is_initialized(project.name) and not dry_run:
|
|
225
309
|
raise RcloneError(
|
|
226
310
|
f"First bisync for {project.name} requires --resync to establish baseline.\n"
|
|
227
311
|
f"Run: bm project bisync --name {project.name} --resync"
|
|
228
312
|
)
|
|
229
313
|
|
|
230
|
-
result =
|
|
314
|
+
result = run(cmd, text=True)
|
|
231
315
|
return result.returncode == 0
|
|
232
316
|
|
|
233
317
|
|
|
@@ -235,6 +319,10 @@ def project_check(
|
|
|
235
319
|
project: SyncProject,
|
|
236
320
|
bucket_name: str,
|
|
237
321
|
one_way: bool = False,
|
|
322
|
+
*,
|
|
323
|
+
run: RunFunc = subprocess.run,
|
|
324
|
+
is_installed: IsInstalledFunc = is_rclone_installed,
|
|
325
|
+
filter_path: Path | None = None,
|
|
238
326
|
) -> bool:
|
|
239
327
|
"""Check integrity between local and cloud.
|
|
240
328
|
|
|
@@ -249,14 +337,16 @@ def project_check(
|
|
|
249
337
|
True if files match, False if differences found
|
|
250
338
|
|
|
251
339
|
Raises:
|
|
252
|
-
RcloneError: If project has no local_sync_path configured
|
|
340
|
+
RcloneError: If project has no local_sync_path configured or rclone not installed
|
|
253
341
|
"""
|
|
342
|
+
check_rclone_installed(is_installed=is_installed)
|
|
343
|
+
|
|
254
344
|
if not project.local_sync_path:
|
|
255
345
|
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
|
|
256
346
|
|
|
257
347
|
local_path = Path(project.local_sync_path).expanduser()
|
|
258
348
|
remote_path = get_project_remote(project, bucket_name)
|
|
259
|
-
filter_path = get_bmignore_filter_path()
|
|
349
|
+
filter_path = filter_path or get_bmignore_filter_path()
|
|
260
350
|
|
|
261
351
|
cmd = [
|
|
262
352
|
"rclone",
|
|
@@ -270,7 +360,7 @@ def project_check(
|
|
|
270
360
|
if one_way:
|
|
271
361
|
cmd.append("--one-way")
|
|
272
362
|
|
|
273
|
-
result =
|
|
363
|
+
result = run(cmd, capture_output=True, text=True)
|
|
274
364
|
return result.returncode == 0
|
|
275
365
|
|
|
276
366
|
|
|
@@ -278,6 +368,9 @@ def project_ls(
|
|
|
278
368
|
project: SyncProject,
|
|
279
369
|
bucket_name: str,
|
|
280
370
|
path: Optional[str] = None,
|
|
371
|
+
*,
|
|
372
|
+
run: RunFunc = subprocess.run,
|
|
373
|
+
is_installed: IsInstalledFunc = is_rclone_installed,
|
|
281
374
|
) -> list[str]:
|
|
282
375
|
"""List files in remote project.
|
|
283
376
|
|
|
@@ -291,11 +384,14 @@ def project_ls(
|
|
|
291
384
|
|
|
292
385
|
Raises:
|
|
293
386
|
subprocess.CalledProcessError: If rclone command fails
|
|
387
|
+
RcloneError: If rclone is not installed
|
|
294
388
|
"""
|
|
389
|
+
check_rclone_installed(is_installed=is_installed)
|
|
390
|
+
|
|
295
391
|
remote_path = get_project_remote(project, bucket_name)
|
|
296
392
|
if path:
|
|
297
393
|
remote_path = f"{remote_path}/{path}"
|
|
298
394
|
|
|
299
395
|
cmd = ["rclone", "ls", remote_path]
|
|
300
|
-
result =
|
|
396
|
+
result = run(cmd, capture_output=True, text=True, check=True)
|
|
301
397
|
return result.stdout.splitlines()
|
|
@@ -151,11 +151,25 @@ def install_rclone_windows() -> None:
|
|
|
151
151
|
except RcloneInstallError:
|
|
152
152
|
console.print("[yellow]scoop installation failed[/yellow]")
|
|
153
153
|
|
|
154
|
-
# No package manager available
|
|
155
|
-
|
|
156
|
-
"Could not install rclone automatically
|
|
157
|
-
"
|
|
154
|
+
# No package manager available - provide detailed instructions
|
|
155
|
+
error_msg = (
|
|
156
|
+
"Could not install rclone automatically.\n\n"
|
|
157
|
+
"Windows requires a package manager to install rclone. Options:\n\n"
|
|
158
|
+
"1. Install winget (recommended, built into Windows 11):\n"
|
|
159
|
+
" - Windows 11: Already installed\n"
|
|
160
|
+
" - Windows 10: Install 'App Installer' from Microsoft Store\n"
|
|
161
|
+
" - Then run: bm cloud setup\n\n"
|
|
162
|
+
"2. Install chocolatey:\n"
|
|
163
|
+
" - Visit: https://chocolatey.org/install\n"
|
|
164
|
+
" - Then run: bm cloud setup\n\n"
|
|
165
|
+
"3. Install scoop:\n"
|
|
166
|
+
" - Visit: https://scoop.sh\n"
|
|
167
|
+
" - Then run: bm cloud setup\n\n"
|
|
168
|
+
"4. Manual installation:\n"
|
|
169
|
+
" - Download from: https://rclone.org/downloads/\n"
|
|
170
|
+
" - Extract and add to PATH\n"
|
|
158
171
|
)
|
|
172
|
+
raise RcloneInstallError(error_msg)
|
|
159
173
|
|
|
160
174
|
|
|
161
175
|
def install_rclone(platform_override: Optional[str] = None) -> None:
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from contextlib import AbstractAsyncContextManager
|
|
6
|
+
from typing import Callable
|
|
5
7
|
|
|
6
8
|
import aiofiles
|
|
7
9
|
import httpx
|
|
@@ -20,6 +22,9 @@ async def upload_path(
|
|
|
20
22
|
verbose: bool = False,
|
|
21
23
|
use_gitignore: bool = True,
|
|
22
24
|
dry_run: bool = False,
|
|
25
|
+
*,
|
|
26
|
+
client_cm_factory: Callable[[], AbstractAsyncContextManager[httpx.AsyncClient]] | None = None,
|
|
27
|
+
put_func=call_put,
|
|
23
28
|
) -> bool:
|
|
24
29
|
"""
|
|
25
30
|
Upload a file or directory to cloud project via WebDAV.
|
|
@@ -85,8 +90,10 @@ async def upload_path(
|
|
|
85
90
|
size_str = f"{size / (1024 * 1024):.1f} MB"
|
|
86
91
|
print(f" {relative_path} ({size_str})")
|
|
87
92
|
else:
|
|
88
|
-
# Upload files using httpx
|
|
89
|
-
|
|
93
|
+
# Upload files using httpx.
|
|
94
|
+
# Allow injection for tests (MockTransport) while keeping production default.
|
|
95
|
+
cm_factory = client_cm_factory or get_client
|
|
96
|
+
async with cm_factory() as client:
|
|
90
97
|
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
|
|
91
98
|
# Skip archive files (zip, tar, gz, etc.)
|
|
92
99
|
if _is_archive_file(file_path):
|
|
@@ -110,7 +117,7 @@ async def upload_path(
|
|
|
110
117
|
|
|
111
118
|
# Upload via HTTP PUT to WebDAV endpoint with mtime header
|
|
112
119
|
# Using X-OC-Mtime (ownCloud/Nextcloud standard)
|
|
113
|
-
response = await
|
|
120
|
+
response = await put_func(
|
|
114
121
|
client, remote_path, content=content, headers={"X-OC-Mtime": str(mtime)}
|
|
115
122
|
)
|
|
116
123
|
response.raise_for_status()
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"""utility functions for commands"""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Optional, TypeVar, Coroutine, Any
|
|
4
5
|
|
|
5
6
|
from mcp.server.fastmcp.exceptions import ToolError
|
|
6
7
|
import typer
|
|
7
8
|
|
|
8
9
|
from rich.console import Console
|
|
9
10
|
|
|
11
|
+
from basic_memory import db
|
|
10
12
|
from basic_memory.mcp.async_client import get_client
|
|
11
13
|
|
|
12
14
|
from basic_memory.mcp.tools.utils import call_post, call_get
|
|
@@ -15,24 +17,70 @@ from basic_memory.schemas import ProjectInfoResponse
|
|
|
15
17
|
|
|
16
18
|
console = Console()
|
|
17
19
|
|
|
20
|
+
T = TypeVar("T")
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
|
|
23
|
+
def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
|
|
24
|
+
"""Run an async coroutine with proper database cleanup.
|
|
25
|
+
|
|
26
|
+
This helper ensures database connections are cleaned up before the event
|
|
27
|
+
loop closes, preventing process hangs in CLI commands.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
coro: The coroutine to run
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The result of the coroutine
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
async def _with_cleanup() -> T:
|
|
37
|
+
try:
|
|
38
|
+
return await coro
|
|
39
|
+
finally:
|
|
40
|
+
await db.shutdown_db()
|
|
41
|
+
|
|
42
|
+
return asyncio.run(_with_cleanup())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def run_sync(
|
|
46
|
+
project: Optional[str] = None,
|
|
47
|
+
force_full: bool = False,
|
|
48
|
+
run_in_background: bool = True,
|
|
49
|
+
):
|
|
20
50
|
"""Run sync operation via API endpoint.
|
|
21
51
|
|
|
22
52
|
Args:
|
|
23
53
|
project: Optional project name
|
|
24
54
|
force_full: If True, force a full scan bypassing watermark optimization
|
|
55
|
+
run_in_background: If True, return immediately; if False, wait for completion
|
|
25
56
|
"""
|
|
26
57
|
|
|
27
58
|
try:
|
|
28
59
|
async with get_client() as client:
|
|
29
60
|
project_item = await get_active_project(client, project, None)
|
|
30
61
|
url = f"{project_item.project_url}/project/sync"
|
|
62
|
+
params = []
|
|
31
63
|
if force_full:
|
|
32
|
-
|
|
64
|
+
params.append("force_full=true")
|
|
65
|
+
if not run_in_background:
|
|
66
|
+
params.append("run_in_background=false")
|
|
67
|
+
if params:
|
|
68
|
+
url += "?" + "&".join(params)
|
|
33
69
|
response = await call_post(client, url)
|
|
34
70
|
data = response.json()
|
|
35
|
-
|
|
71
|
+
# Background mode returns {"message": "..."}, foreground returns SyncReportResponse
|
|
72
|
+
if "message" in data:
|
|
73
|
+
console.print(f"[green]{data['message']}[/green]")
|
|
74
|
+
else:
|
|
75
|
+
# Foreground mode - show summary of sync results
|
|
76
|
+
total = data.get("total", 0)
|
|
77
|
+
new_count = len(data.get("new", []))
|
|
78
|
+
modified_count = len(data.get("modified", []))
|
|
79
|
+
deleted_count = len(data.get("deleted", []))
|
|
80
|
+
console.print(
|
|
81
|
+
f"[green]Synced {total} files[/green] "
|
|
82
|
+
f"(new: {new_count}, modified: {modified_count}, deleted: {deleted_count})"
|
|
83
|
+
)
|
|
36
84
|
except (ToolError, ValueError) as e:
|
|
37
85
|
console.print(f"[red]Sync failed: {e}[/red]")
|
|
38
86
|
raise typer.Exit(1)
|