basic-memory 0.17.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,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
@@ -0,0 +1,263 @@
1
+ """Cross-platform rclone installation utilities."""
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+ from typing import Optional
8
+
9
+ from rich.console import Console
10
+
11
+ console = Console()
12
+
13
+
14
+ class RcloneInstallError(Exception):
15
+ """Exception raised for rclone installation errors."""
16
+
17
+ pass
18
+
19
+
20
+ def is_rclone_installed() -> bool:
21
+ """Check if rclone is already installed and available in PATH."""
22
+ return shutil.which("rclone") is not None
23
+
24
+
25
+ def get_platform() -> str:
26
+ """Get the current platform identifier."""
27
+ system = platform.system().lower()
28
+ if system == "darwin":
29
+ return "macos"
30
+ elif system == "linux":
31
+ return "linux"
32
+ elif system == "windows":
33
+ return "windows"
34
+ else:
35
+ raise RcloneInstallError(f"Unsupported platform: {system}")
36
+
37
+
38
+ def run_command(command: list[str], check: bool = True) -> subprocess.CompletedProcess:
39
+ """Run a command with proper error handling."""
40
+ try:
41
+ console.print(f"[dim]Running: {' '.join(command)}[/dim]")
42
+ result = subprocess.run(command, capture_output=True, text=True, check=check)
43
+ if result.stdout:
44
+ console.print(f"[dim]Output: {result.stdout.strip()}[/dim]")
45
+ return result
46
+ except subprocess.CalledProcessError as e:
47
+ console.print(f"[red]Command failed: {e}[/red]")
48
+ if e.stderr:
49
+ console.print(f"[red]Error output: {e.stderr}[/red]")
50
+ raise RcloneInstallError(f"Command failed: {e}") from e
51
+ except FileNotFoundError as e:
52
+ raise RcloneInstallError(f"Command not found: {' '.join(command)}") from e
53
+
54
+
55
+ def install_rclone_macos() -> None:
56
+ """Install rclone on macOS using Homebrew or official script."""
57
+ # Try Homebrew first
58
+ if shutil.which("brew"):
59
+ try:
60
+ console.print("[blue]Installing rclone via Homebrew...[/blue]")
61
+ run_command(["brew", "install", "rclone"])
62
+ console.print("[green]rclone installed via Homebrew[/green]")
63
+ return
64
+ except RcloneInstallError:
65
+ console.print(
66
+ "[yellow]Homebrew installation failed, trying official script...[/yellow]"
67
+ )
68
+
69
+ # Fallback to official script
70
+ console.print("[blue]Installing rclone via official script...[/blue]")
71
+ try:
72
+ run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
73
+ console.print("[green]rclone installed via official script[/green]")
74
+ except RcloneInstallError:
75
+ raise RcloneInstallError(
76
+ "Failed to install rclone. Please install manually: brew install rclone"
77
+ )
78
+
79
+
80
+ def install_rclone_linux() -> None:
81
+ """Install rclone on Linux using package managers or official script."""
82
+ # Try snap first (most universal)
83
+ if shutil.which("snap"):
84
+ try:
85
+ console.print("[blue]Installing rclone via snap...[/blue]")
86
+ run_command(["sudo", "snap", "install", "rclone"])
87
+ console.print("[green]rclone installed via snap[/green]")
88
+ return
89
+ except RcloneInstallError:
90
+ console.print("[yellow]Snap installation failed, trying apt...[/yellow]")
91
+
92
+ # Try apt (Debian/Ubuntu)
93
+ if shutil.which("apt"):
94
+ try:
95
+ console.print("[blue]Installing rclone via apt...[/blue]")
96
+ run_command(["sudo", "apt", "update"])
97
+ run_command(["sudo", "apt", "install", "-y", "rclone"])
98
+ console.print("[green]rclone installed via apt[/green]")
99
+ return
100
+ except RcloneInstallError:
101
+ console.print("[yellow]apt installation failed, trying official script...[/yellow]")
102
+
103
+ # Fallback to official script
104
+ console.print("[blue]Installing rclone via official script...[/blue]")
105
+ try:
106
+ run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
107
+ console.print("[green]rclone installed via official script[/green]")
108
+ except RcloneInstallError:
109
+ raise RcloneInstallError(
110
+ "Failed to install rclone. Please install manually: sudo snap install rclone"
111
+ )
112
+
113
+
114
+ def install_rclone_windows() -> None:
115
+ """Install rclone on Windows using package managers."""
116
+ # Try winget first (built into Windows 10+)
117
+ if shutil.which("winget"):
118
+ try:
119
+ console.print("[blue]Installing rclone via winget...[/blue]")
120
+ run_command(
121
+ [
122
+ "winget",
123
+ "install",
124
+ "Rclone.Rclone",
125
+ "--accept-source-agreements",
126
+ "--accept-package-agreements",
127
+ ]
128
+ )
129
+ console.print("[green]rclone installed via winget[/green]")
130
+ return
131
+ except RcloneInstallError:
132
+ console.print("[yellow]winget installation failed, trying chocolatey...[/yellow]")
133
+
134
+ # Try chocolatey
135
+ if shutil.which("choco"):
136
+ try:
137
+ console.print("[blue]Installing rclone via chocolatey...[/blue]")
138
+ run_command(["choco", "install", "rclone", "-y"])
139
+ console.print("[green]rclone installed via chocolatey[/green]")
140
+ return
141
+ except RcloneInstallError:
142
+ console.print("[yellow]chocolatey installation failed, trying scoop...[/yellow]")
143
+
144
+ # Try scoop
145
+ if shutil.which("scoop"):
146
+ try:
147
+ console.print("[blue]Installing rclone via scoop...[/blue]")
148
+ run_command(["scoop", "install", "rclone"])
149
+ console.print("[green]rclone installed via scoop[/green]")
150
+ return
151
+ except RcloneInstallError:
152
+ console.print("[yellow]scoop installation failed[/yellow]")
153
+
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"
171
+ )
172
+ raise RcloneInstallError(error_msg)
173
+
174
+
175
+ def install_rclone(platform_override: Optional[str] = None) -> None:
176
+ """Install rclone for the current platform."""
177
+ if is_rclone_installed():
178
+ console.print("[green]rclone is already installed[/green]")
179
+ return
180
+
181
+ platform_name = platform_override or get_platform()
182
+ console.print(f"[blue]Installing rclone for {platform_name}...[/blue]")
183
+
184
+ try:
185
+ if platform_name == "macos":
186
+ install_rclone_macos()
187
+ elif platform_name == "linux":
188
+ install_rclone_linux()
189
+ elif platform_name == "windows":
190
+ install_rclone_windows()
191
+ refresh_windows_path()
192
+ else:
193
+ raise RcloneInstallError(f"Unsupported platform: {platform_name}")
194
+
195
+ # Verify installation
196
+ if not is_rclone_installed():
197
+ raise RcloneInstallError("rclone installation completed but command not found in PATH")
198
+
199
+ console.print("[green]rclone installation completed successfully[/green]")
200
+
201
+ except RcloneInstallError:
202
+ raise
203
+ except Exception as e:
204
+ raise RcloneInstallError(f"Unexpected error during installation: {e}") from e
205
+
206
+
207
+ def refresh_windows_path() -> None:
208
+ """Refresh the Windows PATH environment variable for the current session."""
209
+ if platform.system().lower() != "windows":
210
+ return
211
+
212
+ # Importing here after performing platform detection. Also note that we have to ignore pylance/pyright
213
+ # warnings about winreg attributes so that "errors" don't appear on non-Windows platforms.
214
+ import winreg
215
+
216
+ user_key_path = r"Environment"
217
+ system_key_path = r"System\CurrentControlSet\Control\Session Manager\Environment"
218
+ new_path = ""
219
+
220
+ # Read user PATH
221
+ try:
222
+ reg_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, user_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
223
+ user_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
224
+ winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
225
+ except Exception:
226
+ user_path = ""
227
+
228
+ # Read system PATH
229
+ try:
230
+ reg_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, system_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
231
+ system_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
232
+ winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
233
+ except Exception:
234
+ system_path = ""
235
+
236
+ # Merge user and system PATHs (system first, then user)
237
+ if system_path and user_path:
238
+ new_path = system_path + ";" + user_path
239
+ elif system_path:
240
+ new_path = system_path
241
+ elif user_path:
242
+ new_path = user_path
243
+
244
+ if new_path:
245
+ os.environ["PATH"] = new_path
246
+
247
+
248
+ def get_rclone_version() -> Optional[str]:
249
+ """Get the installed rclone version."""
250
+ if not is_rclone_installed():
251
+ return None
252
+
253
+ try:
254
+ result = run_command(["rclone", "version"], check=False)
255
+ if result.returncode == 0:
256
+ # Parse version from output (format: "rclone v1.64.0")
257
+ lines = result.stdout.strip().split("\n")
258
+ for line in lines:
259
+ if line.startswith("rclone v"):
260
+ return line.split()[1]
261
+ return "unknown"
262
+ except Exception:
263
+ return "unknown"
@@ -0,0 +1,233 @@
1
+ """WebDAV upload functionality for basic-memory projects."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import aiofiles
7
+ import httpx
8
+
9
+ from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
10
+ from basic_memory.mcp.async_client import get_client
11
+ from basic_memory.mcp.tools.utils import call_put
12
+
13
+ # Archive file extensions that should be skipped during upload
14
+ ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".tbz2"}
15
+
16
+
17
+ async def upload_path(
18
+ local_path: Path,
19
+ project_name: str,
20
+ verbose: bool = False,
21
+ use_gitignore: bool = True,
22
+ dry_run: bool = False,
23
+ ) -> bool:
24
+ """
25
+ Upload a file or directory to cloud project via WebDAV.
26
+
27
+ Args:
28
+ local_path: Path to local file or directory
29
+ project_name: Name of cloud project (destination)
30
+ verbose: Show detailed information about filtering and upload
31
+ use_gitignore: If False, skip .gitignore patterns (still use .bmignore)
32
+ dry_run: If True, show what would be uploaded without uploading
33
+
34
+ Returns:
35
+ True if upload succeeded, False otherwise
36
+ """
37
+ try:
38
+ # Resolve path
39
+ local_path = local_path.resolve()
40
+
41
+ # Check if path exists
42
+ if not local_path.exists():
43
+ print(f"Error: Path does not exist: {local_path}")
44
+ return False
45
+
46
+ # Get files to upload
47
+ if local_path.is_file():
48
+ files_to_upload = [(local_path, local_path.name)]
49
+ if verbose:
50
+ print(f"Uploading single file: {local_path.name}")
51
+ else:
52
+ files_to_upload = _get_files_to_upload(local_path, verbose, use_gitignore)
53
+
54
+ if not files_to_upload:
55
+ print("No files found to upload")
56
+ if verbose:
57
+ print(
58
+ "\nTip: Use --verbose to see which files are being filtered, "
59
+ "or --no-gitignore to skip .gitignore patterns"
60
+ )
61
+ return True
62
+
63
+ print(f"Found {len(files_to_upload)} file(s) to upload")
64
+
65
+ # Calculate total size
66
+ total_bytes = sum(file_path.stat().st_size for file_path, _ in files_to_upload)
67
+ skipped_count = 0
68
+
69
+ # If dry run, just show what would be uploaded
70
+ if dry_run:
71
+ print("\nFiles that would be uploaded:")
72
+ for file_path, relative_path in files_to_upload:
73
+ # Skip archive files
74
+ if _is_archive_file(file_path):
75
+ print(f" [SKIP] {relative_path} (archive file)")
76
+ skipped_count += 1
77
+ continue
78
+
79
+ size = file_path.stat().st_size
80
+ if size < 1024:
81
+ size_str = f"{size} bytes"
82
+ elif size < 1024 * 1024:
83
+ size_str = f"{size / 1024:.1f} KB"
84
+ else:
85
+ size_str = f"{size / (1024 * 1024):.1f} MB"
86
+ print(f" {relative_path} ({size_str})")
87
+ else:
88
+ # Upload files using httpx
89
+ async with get_client() as client:
90
+ for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
91
+ # Skip archive files (zip, tar, gz, etc.)
92
+ if _is_archive_file(file_path):
93
+ print(
94
+ f"Skipping archive file: {relative_path} ({i}/{len(files_to_upload)})"
95
+ )
96
+ skipped_count += 1
97
+ continue
98
+
99
+ # Build remote path: /webdav/{project_name}/{relative_path}
100
+ remote_path = f"/webdav/{project_name}/{relative_path}"
101
+ print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")
102
+
103
+ # Get file modification time
104
+ file_stat = file_path.stat()
105
+ mtime = int(file_stat.st_mtime)
106
+
107
+ # Read file content asynchronously
108
+ async with aiofiles.open(file_path, "rb") as f:
109
+ content = await f.read()
110
+
111
+ # Upload via HTTP PUT to WebDAV endpoint with mtime header
112
+ # Using X-OC-Mtime (ownCloud/Nextcloud standard)
113
+ response = await call_put(
114
+ client, remote_path, content=content, headers={"X-OC-Mtime": str(mtime)}
115
+ )
116
+ response.raise_for_status()
117
+
118
+ # Format total size based on magnitude
119
+ if total_bytes < 1024:
120
+ size_str = f"{total_bytes} bytes"
121
+ elif total_bytes < 1024 * 1024:
122
+ size_str = f"{total_bytes / 1024:.1f} KB"
123
+ else:
124
+ size_str = f"{total_bytes / (1024 * 1024):.1f} MB"
125
+
126
+ uploaded_count = len(files_to_upload) - skipped_count
127
+ if dry_run:
128
+ print(f"\nTotal: {uploaded_count} file(s) ({size_str})")
129
+ if skipped_count > 0:
130
+ print(f" Would skip {skipped_count} archive file(s)")
131
+ else:
132
+ print(f"✓ Upload complete: {uploaded_count} file(s) ({size_str})")
133
+ if skipped_count > 0:
134
+ print(f" Skipped {skipped_count} archive file(s)")
135
+
136
+ return True
137
+
138
+ except httpx.HTTPStatusError as e:
139
+ print(f"Upload failed: HTTP {e.response.status_code} - {e.response.text}")
140
+ return False
141
+ except Exception as e:
142
+ print(f"Upload failed: {e}")
143
+ return False
144
+
145
+
146
+ def _is_archive_file(file_path: Path) -> bool:
147
+ """
148
+ Check if a file is an archive file based on its extension.
149
+
150
+ Args:
151
+ file_path: Path to the file to check
152
+
153
+ Returns:
154
+ True if file is an archive, False otherwise
155
+ """
156
+ return file_path.suffix.lower() in ARCHIVE_EXTENSIONS
157
+
158
+
159
+ def _get_files_to_upload(
160
+ directory: Path, verbose: bool = False, use_gitignore: bool = True
161
+ ) -> list[tuple[Path, str]]:
162
+ """
163
+ Get list of files to upload from directory.
164
+
165
+ Uses .bmignore and optionally .gitignore patterns for filtering.
166
+
167
+ Args:
168
+ directory: Directory to scan
169
+ verbose: Show detailed filtering information
170
+ use_gitignore: If False, skip .gitignore patterns (still use .bmignore)
171
+
172
+ Returns:
173
+ List of (absolute_path, relative_path) tuples
174
+ """
175
+ files = []
176
+ ignored_files = []
177
+
178
+ # Load ignore patterns from .bmignore and optionally .gitignore
179
+ ignore_patterns = load_gitignore_patterns(directory, use_gitignore=use_gitignore)
180
+
181
+ if verbose:
182
+ gitignore_path = directory / ".gitignore"
183
+ gitignore_exists = gitignore_path.exists() and use_gitignore
184
+ print(f"\nScanning directory: {directory}")
185
+ print("Using .bmignore: Yes")
186
+ print(f"Using .gitignore: {'Yes' if gitignore_exists else 'No'}")
187
+ print(f"Ignore patterns loaded: {len(ignore_patterns)}")
188
+ if ignore_patterns and len(ignore_patterns) <= 20:
189
+ print(f"Patterns: {', '.join(sorted(ignore_patterns))}")
190
+ print()
191
+
192
+ # Walk through directory
193
+ for root, dirs, filenames in os.walk(directory):
194
+ root_path = Path(root)
195
+
196
+ # Filter directories based on ignore patterns
197
+ filtered_dirs = []
198
+ for d in dirs:
199
+ dir_path = root_path / d
200
+ if should_ignore_path(dir_path, directory, ignore_patterns):
201
+ if verbose:
202
+ rel_path = dir_path.relative_to(directory)
203
+ print(f" [IGNORED DIR] {rel_path}/")
204
+ else:
205
+ filtered_dirs.append(d)
206
+ dirs[:] = filtered_dirs
207
+
208
+ # Process files
209
+ for filename in filenames:
210
+ file_path = root_path / filename
211
+
212
+ # Calculate relative path for display/remote
213
+ rel_path = file_path.relative_to(directory)
214
+ remote_path = str(rel_path).replace("\\", "/")
215
+
216
+ # Check if file should be ignored
217
+ if should_ignore_path(file_path, directory, ignore_patterns):
218
+ ignored_files.append(remote_path)
219
+ if verbose:
220
+ print(f" [IGNORED] {remote_path}")
221
+ continue
222
+
223
+ if verbose:
224
+ print(f" [INCLUDE] {remote_path}")
225
+
226
+ files.append((file_path, remote_path))
227
+
228
+ if verbose:
229
+ print("\nSummary:")
230
+ print(f" Files to upload: {len(files)}")
231
+ print(f" Files ignored: {len(ignored_files)}")
232
+
233
+ return files