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,889 @@
1
+ """Command module for basic-memory project management."""
2
+
3
+ import asyncio
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from basic_memory.cli.app import app
12
+ from basic_memory.cli.commands.command_utils import get_project_info
13
+ from basic_memory.config import ConfigManager
14
+ import json
15
+ from datetime import datetime
16
+
17
+ from rich.panel import Panel
18
+ from basic_memory.mcp.async_client import get_client
19
+ from basic_memory.mcp.tools.utils import call_get, call_post, call_delete, call_put, call_patch
20
+ from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
21
+ from basic_memory.utils import generate_permalink, normalize_project_path
22
+
23
+ # Import rclone commands for project sync
24
+ from basic_memory.cli.commands.cloud.rclone_commands import (
25
+ SyncProject,
26
+ RcloneError,
27
+ project_sync,
28
+ project_bisync,
29
+ project_check,
30
+ project_ls,
31
+ )
32
+ from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
33
+
34
+ console = Console()
35
+
36
+ # Create a project subcommand
37
+ project_app = typer.Typer(help="Manage multiple Basic Memory projects")
38
+ app.add_typer(project_app, name="project")
39
+
40
+
41
+ def format_path(path: str) -> str:
42
+ """Format a path for display, using ~ for home directory."""
43
+ home = str(Path.home())
44
+ if path.startswith(home):
45
+ return path.replace(home, "~", 1) # pragma: no cover
46
+ return path
47
+
48
+
49
+ @project_app.command("list")
50
+ def list_projects() -> None:
51
+ """List all Basic Memory projects."""
52
+
53
+ async def _list_projects():
54
+ async with get_client() as client:
55
+ response = await call_get(client, "/projects/projects")
56
+ return ProjectList.model_validate(response.json())
57
+
58
+ try:
59
+ result = asyncio.run(_list_projects())
60
+ config = ConfigManager().config
61
+
62
+ table = Table(title="Basic Memory Projects")
63
+ table.add_column("Name", style="cyan")
64
+ table.add_column("Path", style="green")
65
+
66
+ # Add Local Path column if in cloud mode
67
+ if config.cloud_mode_enabled:
68
+ table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
69
+
70
+ # Show Default column in local mode or if default_project_mode is enabled in cloud mode
71
+ show_default_column = not config.cloud_mode_enabled or config.default_project_mode
72
+ if show_default_column:
73
+ table.add_column("Default", style="magenta")
74
+
75
+ for project in result.projects:
76
+ is_default = "[X]" if project.is_default else ""
77
+ normalized_path = normalize_project_path(project.path)
78
+
79
+ # Build row based on mode
80
+ row = [project.name, format_path(normalized_path)]
81
+
82
+ # Add local path if in cloud mode
83
+ if config.cloud_mode_enabled:
84
+ local_path = ""
85
+ if project.name in config.cloud_projects:
86
+ local_path = config.cloud_projects[project.name].local_path or ""
87
+ local_path = format_path(local_path)
88
+ row.append(local_path)
89
+
90
+ # Add default indicator if showing default column
91
+ if show_default_column:
92
+ row.append(is_default)
93
+
94
+ table.add_row(*row)
95
+
96
+ console.print(table)
97
+ except Exception as e:
98
+ console.print(f"[red]Error listing projects: {str(e)}[/red]")
99
+ raise typer.Exit(1)
100
+
101
+
102
+ @project_app.command("add")
103
+ def add_project(
104
+ name: str = typer.Argument(..., help="Name of the project"),
105
+ path: str = typer.Argument(
106
+ None, help="Path to the project directory (required for local mode)"
107
+ ),
108
+ local_path: str = typer.Option(
109
+ None, "--local-path", help="Local sync path for cloud mode (optional)"
110
+ ),
111
+ set_default: bool = typer.Option(False, "--default", help="Set as default project"),
112
+ ) -> None:
113
+ """Add a new project.
114
+
115
+ Cloud mode examples:\n
116
+ bm project add research # No local sync\n
117
+ bm project add research --local-path ~/docs # With local sync\n
118
+
119
+ Local mode example:\n
120
+ bm project add research ~/Documents/research
121
+ """
122
+ config = ConfigManager().config
123
+
124
+ # Resolve local sync path early (needed for both cloud and local mode)
125
+ local_sync_path: str | None = None
126
+ if local_path:
127
+ local_sync_path = Path(os.path.abspath(os.path.expanduser(local_path))).as_posix()
128
+
129
+ if config.cloud_mode_enabled:
130
+ # Cloud mode: path auto-generated from name, local sync is optional
131
+
132
+ async def _add_project():
133
+ async with get_client() as client:
134
+ data = {
135
+ "name": name,
136
+ "path": generate_permalink(name),
137
+ "local_sync_path": local_sync_path,
138
+ "set_default": set_default,
139
+ }
140
+ response = await call_post(client, "/projects/projects", json=data)
141
+ return ProjectStatusResponse.model_validate(response.json())
142
+ else:
143
+ # Local mode: path is required
144
+ if path is None:
145
+ console.print("[red]Error: path argument is required in local mode[/red]")
146
+ raise typer.Exit(1)
147
+
148
+ # Resolve to absolute path
149
+ resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
150
+
151
+ async def _add_project():
152
+ async with get_client() as client:
153
+ data = {"name": name, "path": resolved_path, "set_default": set_default}
154
+ response = await call_post(client, "/projects/projects", json=data)
155
+ return ProjectStatusResponse.model_validate(response.json())
156
+
157
+ try:
158
+ result = asyncio.run(_add_project())
159
+ console.print(f"[green]{result.message}[/green]")
160
+
161
+ # Save local sync path to config if in cloud mode
162
+ if config.cloud_mode_enabled and local_sync_path:
163
+ from basic_memory.config import CloudProjectConfig
164
+
165
+ # Create local directory if it doesn't exist
166
+ local_dir = Path(local_sync_path)
167
+ local_dir.mkdir(parents=True, exist_ok=True)
168
+
169
+ # Update config with sync path
170
+ config.cloud_projects[name] = CloudProjectConfig(
171
+ local_path=local_sync_path,
172
+ last_sync=None,
173
+ bisync_initialized=False,
174
+ )
175
+ ConfigManager().save_config(config)
176
+
177
+ console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
178
+ console.print("\nNext steps:")
179
+ console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
180
+ console.print(f" 2. Sync: bm project bisync --name {name} --resync")
181
+ except Exception as e:
182
+ console.print(f"[red]Error adding project: {str(e)}[/red]")
183
+ raise typer.Exit(1)
184
+
185
+
186
+ @project_app.command("sync-setup")
187
+ def setup_project_sync(
188
+ name: str = typer.Argument(..., help="Project name"),
189
+ local_path: str = typer.Argument(..., help="Local sync directory"),
190
+ ) -> None:
191
+ """Configure local sync for an existing cloud project.
192
+
193
+ Example:
194
+ bm project sync-setup research ~/Documents/research
195
+ """
196
+ config_manager = ConfigManager()
197
+ config = config_manager.config
198
+
199
+ if not config.cloud_mode_enabled:
200
+ console.print("[red]Error: sync-setup only available in cloud mode[/red]")
201
+ raise typer.Exit(1)
202
+
203
+ async def _verify_project_exists():
204
+ """Verify the project exists on cloud by listing all projects."""
205
+ async with get_client() as client:
206
+ response = await call_get(client, "/projects/projects")
207
+ project_list = response.json()
208
+ project_names = [p["name"] for p in project_list["projects"]]
209
+ if name not in project_names:
210
+ raise ValueError(f"Project '{name}' not found on cloud")
211
+ return True
212
+
213
+ try:
214
+ # Verify project exists on cloud
215
+ asyncio.run(_verify_project_exists())
216
+
217
+ # Resolve and create local path
218
+ resolved_path = Path(os.path.abspath(os.path.expanduser(local_path)))
219
+ resolved_path.mkdir(parents=True, exist_ok=True)
220
+
221
+ # Update local config with sync path
222
+ from basic_memory.config import CloudProjectConfig
223
+
224
+ config.cloud_projects[name] = CloudProjectConfig(
225
+ local_path=resolved_path.as_posix(),
226
+ last_sync=None,
227
+ bisync_initialized=False,
228
+ )
229
+ config_manager.save_config(config)
230
+
231
+ console.print(f"[green]Sync configured for project '{name}'[/green]")
232
+ console.print(f"\nLocal sync path: {resolved_path}")
233
+ console.print("\nNext steps:")
234
+ console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
235
+ console.print(f" 2. Sync: bm project bisync --name {name} --resync")
236
+ except Exception as e:
237
+ console.print(f"[red]Error configuring sync: {str(e)}[/red]")
238
+ raise typer.Exit(1)
239
+
240
+
241
+ @project_app.command("remove")
242
+ def remove_project(
243
+ name: str = typer.Argument(..., help="Name of the project to remove"),
244
+ delete_notes: bool = typer.Option(
245
+ False, "--delete-notes", help="Delete project files from disk"
246
+ ),
247
+ ) -> None:
248
+ """Remove a project."""
249
+
250
+ async def _remove_project():
251
+ async with get_client() as client:
252
+ # Convert name to permalink for efficient resolution
253
+ project_permalink = generate_permalink(name)
254
+
255
+ # Use v2 project resolver to find project ID by permalink
256
+ resolve_data = {"identifier": project_permalink}
257
+ response = await call_post(client, "/v2/projects/resolve", json=resolve_data)
258
+ target_project = response.json()
259
+
260
+ # Use v2 API with project ID
261
+ response = await call_delete(
262
+ client, f"/v2/projects/{target_project['project_id']}?delete_notes={delete_notes}"
263
+ )
264
+ return ProjectStatusResponse.model_validate(response.json())
265
+
266
+ try:
267
+ # Get config to check for local sync path and bisync state
268
+ config = ConfigManager().config
269
+ local_path = None
270
+ has_bisync_state = False
271
+
272
+ if config.cloud_mode_enabled and name in config.cloud_projects:
273
+ local_path = config.cloud_projects[name].local_path
274
+
275
+ # Check for bisync state
276
+ from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
277
+
278
+ bisync_state_path = get_project_bisync_state(name)
279
+ has_bisync_state = bisync_state_path.exists()
280
+
281
+ # Remove project from cloud/API
282
+ result = asyncio.run(_remove_project())
283
+ console.print(f"[green]{result.message}[/green]")
284
+
285
+ # Clean up local sync directory if it exists and delete_notes is True
286
+ if delete_notes and local_path:
287
+ local_dir = Path(local_path)
288
+ if local_dir.exists():
289
+ import shutil
290
+
291
+ shutil.rmtree(local_dir)
292
+ console.print(f"[green]Removed local sync directory: {local_path}[/green]")
293
+
294
+ # Clean up bisync state if it exists
295
+ if has_bisync_state:
296
+ from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
297
+ import shutil
298
+
299
+ bisync_state_path = get_project_bisync_state(name)
300
+ if bisync_state_path.exists():
301
+ shutil.rmtree(bisync_state_path)
302
+ console.print("[green]Removed bisync state[/green]")
303
+
304
+ # Clean up cloud_projects config entry
305
+ if config.cloud_mode_enabled and name in config.cloud_projects:
306
+ del config.cloud_projects[name]
307
+ ConfigManager().save_config(config)
308
+
309
+ # Show informative message if files were not deleted
310
+ if not delete_notes:
311
+ if local_path:
312
+ console.print(f"[yellow]Note: Local files remain at {local_path}[/yellow]")
313
+
314
+ except Exception as e:
315
+ console.print(f"[red]Error removing project: {str(e)}[/red]")
316
+ raise typer.Exit(1)
317
+
318
+
319
+ @project_app.command("default")
320
+ def set_default_project(
321
+ name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
322
+ ) -> None:
323
+ """Set the default project when 'config.default_project_mode' is set.
324
+
325
+ Note: This command is only available in local mode.
326
+ """
327
+ config = ConfigManager().config
328
+
329
+ if config.cloud_mode_enabled:
330
+ console.print("[red]Error: 'default' command is not available in cloud mode[/red]")
331
+ raise typer.Exit(1)
332
+
333
+ async def _set_default():
334
+ async with get_client() as client:
335
+ # Convert name to permalink for efficient resolution
336
+ project_permalink = generate_permalink(name)
337
+
338
+ # Use v2 project resolver to find project ID by permalink
339
+ resolve_data = {"identifier": project_permalink}
340
+ response = await call_post(client, "/v2/projects/resolve", json=resolve_data)
341
+ target_project = response.json()
342
+
343
+ # Use v2 API with project ID
344
+ response = await call_put(
345
+ client, f"/v2/projects/{target_project['project_id']}/default"
346
+ )
347
+ return ProjectStatusResponse.model_validate(response.json())
348
+
349
+ try:
350
+ result = asyncio.run(_set_default())
351
+ console.print(f"[green]{result.message}[/green]")
352
+ except Exception as e:
353
+ console.print(f"[red]Error setting default project: {str(e)}[/red]")
354
+ raise typer.Exit(1)
355
+
356
+
357
+ @project_app.command("sync-config")
358
+ def synchronize_projects() -> None:
359
+ """Synchronize project config between configuration file and database.
360
+
361
+ Note: This command is only available in local mode.
362
+ """
363
+ config = ConfigManager().config
364
+
365
+ if config.cloud_mode_enabled:
366
+ console.print("[red]Error: 'sync-config' command is not available in cloud mode[/red]")
367
+ raise typer.Exit(1)
368
+
369
+ async def _sync_config():
370
+ async with get_client() as client:
371
+ response = await call_post(client, "/projects/config/sync")
372
+ return ProjectStatusResponse.model_validate(response.json())
373
+
374
+ try:
375
+ result = asyncio.run(_sync_config())
376
+ console.print(f"[green]{result.message}[/green]")
377
+ except Exception as e: # pragma: no cover
378
+ console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
379
+ raise typer.Exit(1)
380
+
381
+
382
+ @project_app.command("move")
383
+ def move_project(
384
+ name: str = typer.Argument(..., help="Name of the project to move"),
385
+ new_path: str = typer.Argument(..., help="New absolute path for the project"),
386
+ ) -> None:
387
+ """Move a project to a new location.
388
+
389
+ Note: This command is only available in local mode.
390
+ """
391
+ config = ConfigManager().config
392
+
393
+ if config.cloud_mode_enabled:
394
+ console.print("[red]Error: 'move' command is not available in cloud mode[/red]")
395
+ raise typer.Exit(1)
396
+
397
+ # Resolve to absolute path
398
+ resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
399
+
400
+ async def _move_project():
401
+ async with get_client() as client:
402
+ data = {"path": resolved_path}
403
+ project_permalink = generate_permalink(name)
404
+
405
+ # TODO fix route to use ProjectPathDep
406
+ response = await call_patch(client, f"/{name}/project/{project_permalink}", json=data)
407
+ return ProjectStatusResponse.model_validate(response.json())
408
+
409
+ try:
410
+ result = asyncio.run(_move_project())
411
+ console.print(f"[green]{result.message}[/green]")
412
+
413
+ # Show important file movement reminder
414
+ console.print() # Empty line for spacing
415
+ console.print(
416
+ Panel(
417
+ "[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
418
+ "[yellow]You must manually move your project files from the old location to:[/yellow]\n"
419
+ f"[cyan]{resolved_path}[/cyan]\n\n"
420
+ "[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
421
+ title="Manual File Movement Required",
422
+ border_style="yellow",
423
+ expand=False,
424
+ )
425
+ )
426
+
427
+ except Exception as e:
428
+ console.print(f"[red]Error moving project: {str(e)}[/red]")
429
+ raise typer.Exit(1)
430
+
431
+
432
+ @project_app.command("sync")
433
+ def sync_project_command(
434
+ name: str = typer.Option(..., "--name", help="Project name to sync"),
435
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
436
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
437
+ ) -> None:
438
+ """One-way sync: local -> cloud (make cloud identical to local).
439
+
440
+ Example:
441
+ bm project sync --name research
442
+ bm project sync --name research --dry-run
443
+ """
444
+ config = ConfigManager().config
445
+ if not config.cloud_mode_enabled:
446
+ console.print("[red]Error: sync only available in cloud mode[/red]")
447
+ raise typer.Exit(1)
448
+
449
+ try:
450
+ # Get tenant info for bucket name
451
+ tenant_info = asyncio.run(get_mount_info())
452
+ bucket_name = tenant_info.bucket_name
453
+
454
+ # Get project info
455
+ async def _get_project():
456
+ async with get_client() as client:
457
+ response = await call_get(client, "/projects/projects")
458
+ projects_list = ProjectList.model_validate(response.json())
459
+ for proj in projects_list.projects:
460
+ if generate_permalink(proj.name) == generate_permalink(name):
461
+ return proj
462
+ return None
463
+
464
+ project_data = asyncio.run(_get_project())
465
+ if not project_data:
466
+ console.print(f"[red]Error: Project '{name}' not found[/red]")
467
+ raise typer.Exit(1)
468
+
469
+ # Get local_sync_path from cloud_projects config
470
+ local_sync_path = None
471
+ if name in config.cloud_projects:
472
+ local_sync_path = config.cloud_projects[name].local_path
473
+
474
+ if not local_sync_path:
475
+ console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
476
+ console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
477
+ raise typer.Exit(1)
478
+
479
+ # Create SyncProject
480
+ sync_project = SyncProject(
481
+ name=project_data.name,
482
+ path=normalize_project_path(project_data.path),
483
+ local_sync_path=local_sync_path,
484
+ )
485
+
486
+ # Run sync
487
+ console.print(f"[blue]Syncing {name} (local -> cloud)...[/blue]")
488
+ success = project_sync(sync_project, bucket_name, dry_run=dry_run, verbose=verbose)
489
+
490
+ if success:
491
+ console.print(f"[green]{name} synced successfully[/green]")
492
+
493
+ # Trigger database sync if not a dry run
494
+ if not dry_run:
495
+
496
+ async def _trigger_db_sync():
497
+ async with get_client() as client:
498
+ permalink = generate_permalink(name)
499
+ response = await call_post(
500
+ client, f"/{permalink}/project/sync?force_full=true", json={}
501
+ )
502
+ return response.json()
503
+
504
+ try:
505
+ result = asyncio.run(_trigger_db_sync())
506
+ console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
507
+ except Exception as e:
508
+ console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
509
+ else:
510
+ console.print(f"[red]{name} sync failed[/red]")
511
+ raise typer.Exit(1)
512
+
513
+ except RcloneError as e:
514
+ console.print(f"[red]Sync error: {e}[/red]")
515
+ raise typer.Exit(1)
516
+ except Exception as e:
517
+ console.print(f"[red]Error: {e}[/red]")
518
+ raise typer.Exit(1)
519
+
520
+
521
+ @project_app.command("bisync")
522
+ def bisync_project_command(
523
+ name: str = typer.Option(..., "--name", help="Project name to bisync"),
524
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
525
+ resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
526
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
527
+ ) -> None:
528
+ """Two-way sync: local <-> cloud (bidirectional sync).
529
+
530
+ Examples:
531
+ bm project bisync --name research --resync # First time
532
+ bm project bisync --name research # Subsequent syncs
533
+ bm project bisync --name research --dry-run # Preview changes
534
+ """
535
+ config = ConfigManager().config
536
+ if not config.cloud_mode_enabled:
537
+ console.print("[red]Error: bisync only available in cloud mode[/red]")
538
+ raise typer.Exit(1)
539
+
540
+ try:
541
+ # Get tenant info for bucket name
542
+ tenant_info = asyncio.run(get_mount_info())
543
+ bucket_name = tenant_info.bucket_name
544
+
545
+ # Get project info
546
+ async def _get_project():
547
+ async with get_client() as client:
548
+ response = await call_get(client, "/projects/projects")
549
+ projects_list = ProjectList.model_validate(response.json())
550
+ for proj in projects_list.projects:
551
+ if generate_permalink(proj.name) == generate_permalink(name):
552
+ return proj
553
+ return None
554
+
555
+ project_data = asyncio.run(_get_project())
556
+ if not project_data:
557
+ console.print(f"[red]Error: Project '{name}' not found[/red]")
558
+ raise typer.Exit(1)
559
+
560
+ # Get local_sync_path from cloud_projects config
561
+ local_sync_path = None
562
+ if name in config.cloud_projects:
563
+ local_sync_path = config.cloud_projects[name].local_path
564
+
565
+ if not local_sync_path:
566
+ console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
567
+ console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
568
+ raise typer.Exit(1)
569
+
570
+ # Create SyncProject
571
+ sync_project = SyncProject(
572
+ name=project_data.name,
573
+ path=normalize_project_path(project_data.path),
574
+ local_sync_path=local_sync_path,
575
+ )
576
+
577
+ # Run bisync
578
+ console.print(f"[blue]Bisync {name} (local <-> cloud)...[/blue]")
579
+ success = project_bisync(
580
+ sync_project, bucket_name, dry_run=dry_run, resync=resync, verbose=verbose
581
+ )
582
+
583
+ if success:
584
+ console.print(f"[green]{name} bisync completed successfully[/green]")
585
+
586
+ # Update config
587
+ config.cloud_projects[name].last_sync = datetime.now()
588
+ config.cloud_projects[name].bisync_initialized = True
589
+ ConfigManager().save_config(config)
590
+
591
+ # Trigger database sync if not a dry run
592
+ if not dry_run:
593
+
594
+ async def _trigger_db_sync():
595
+ async with get_client() as client:
596
+ permalink = generate_permalink(name)
597
+ response = await call_post(
598
+ client, f"/{permalink}/project/sync?force_full=true", json={}
599
+ )
600
+ return response.json()
601
+
602
+ try:
603
+ result = asyncio.run(_trigger_db_sync())
604
+ console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
605
+ except Exception as e:
606
+ console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
607
+ else:
608
+ console.print(f"[red]{name} bisync failed[/red]")
609
+ raise typer.Exit(1)
610
+
611
+ except RcloneError as e:
612
+ console.print(f"[red]Bisync error: {e}[/red]")
613
+ raise typer.Exit(1)
614
+ except Exception as e:
615
+ console.print(f"[red]Error: {e}[/red]")
616
+ raise typer.Exit(1)
617
+
618
+
619
+ @project_app.command("check")
620
+ def check_project_command(
621
+ name: str = typer.Option(..., "--name", help="Project name to check"),
622
+ one_way: bool = typer.Option(False, "--one-way", help="Check one direction only (faster)"),
623
+ ) -> None:
624
+ """Verify file integrity between local and cloud.
625
+
626
+ Example:
627
+ bm project check --name research
628
+ """
629
+ config = ConfigManager().config
630
+ if not config.cloud_mode_enabled:
631
+ console.print("[red]Error: check only available in cloud mode[/red]")
632
+ raise typer.Exit(1)
633
+
634
+ try:
635
+ # Get tenant info for bucket name
636
+ tenant_info = asyncio.run(get_mount_info())
637
+ bucket_name = tenant_info.bucket_name
638
+
639
+ # Get project info
640
+ async def _get_project():
641
+ async with get_client() as client:
642
+ response = await call_get(client, "/projects/projects")
643
+ projects_list = ProjectList.model_validate(response.json())
644
+ for proj in projects_list.projects:
645
+ if generate_permalink(proj.name) == generate_permalink(name):
646
+ return proj
647
+ return None
648
+
649
+ project_data = asyncio.run(_get_project())
650
+ if not project_data:
651
+ console.print(f"[red]Error: Project '{name}' not found[/red]")
652
+ raise typer.Exit(1)
653
+
654
+ # Get local_sync_path from cloud_projects config
655
+ local_sync_path = None
656
+ if name in config.cloud_projects:
657
+ local_sync_path = config.cloud_projects[name].local_path
658
+
659
+ if not local_sync_path:
660
+ console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
661
+ console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
662
+ raise typer.Exit(1)
663
+
664
+ # Create SyncProject
665
+ sync_project = SyncProject(
666
+ name=project_data.name,
667
+ path=normalize_project_path(project_data.path),
668
+ local_sync_path=local_sync_path,
669
+ )
670
+
671
+ # Run check
672
+ console.print(f"[blue]Checking {name} integrity...[/blue]")
673
+ match = project_check(sync_project, bucket_name, one_way=one_way)
674
+
675
+ if match:
676
+ console.print(f"[green]{name} files match[/green]")
677
+ else:
678
+ console.print(f"[yellow]!{name} has differences[/yellow]")
679
+
680
+ except RcloneError as e:
681
+ console.print(f"[red]Check error: {e}[/red]")
682
+ raise typer.Exit(1)
683
+ except Exception as e:
684
+ console.print(f"[red]Error: {e}[/red]")
685
+ raise typer.Exit(1)
686
+
687
+
688
+ @project_app.command("bisync-reset")
689
+ def bisync_reset(
690
+ name: str = typer.Argument(..., help="Project name to reset bisync state for"),
691
+ ) -> None:
692
+ """Clear bisync state for a project.
693
+
694
+ This removes the bisync metadata files, forcing a fresh --resync on next bisync.
695
+ Useful when bisync gets into an inconsistent state or when remote path changes.
696
+ """
697
+ from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
698
+ import shutil
699
+
700
+ try:
701
+ state_path = get_project_bisync_state(name)
702
+
703
+ if not state_path.exists():
704
+ console.print(f"[yellow]No bisync state found for project '{name}'[/yellow]")
705
+ return
706
+
707
+ # Remove the entire state directory
708
+ shutil.rmtree(state_path)
709
+ console.print(f"[green]Cleared bisync state for project '{name}'[/green]")
710
+ console.print("\nNext steps:")
711
+ console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
712
+ console.print(f" 2. Sync: bm project bisync --name {name} --resync")
713
+
714
+ except Exception as e:
715
+ console.print(f"[red]Error clearing bisync state: {str(e)}[/red]")
716
+ raise typer.Exit(1)
717
+
718
+
719
+ @project_app.command("ls")
720
+ def ls_project_command(
721
+ name: str = typer.Option(..., "--name", help="Project name to list files from"),
722
+ path: str = typer.Argument(None, help="Path within project (optional)"),
723
+ ) -> None:
724
+ """List files in remote project.
725
+
726
+ Examples:
727
+ bm project ls --name research
728
+ bm project ls --name research subfolder
729
+ """
730
+ config = ConfigManager().config
731
+ if not config.cloud_mode_enabled:
732
+ console.print("[red]Error: ls only available in cloud mode[/red]")
733
+ raise typer.Exit(1)
734
+
735
+ try:
736
+ # Get tenant info for bucket name
737
+ tenant_info = asyncio.run(get_mount_info())
738
+ bucket_name = tenant_info.bucket_name
739
+
740
+ # Get project info
741
+ async def _get_project():
742
+ async with get_client() as client:
743
+ response = await call_get(client, "/projects/projects")
744
+ projects_list = ProjectList.model_validate(response.json())
745
+ for proj in projects_list.projects:
746
+ if generate_permalink(proj.name) == generate_permalink(name):
747
+ return proj
748
+ return None
749
+
750
+ project_data = asyncio.run(_get_project())
751
+ if not project_data:
752
+ console.print(f"[red]Error: Project '{name}' not found[/red]")
753
+ raise typer.Exit(1)
754
+
755
+ # Create SyncProject (local_sync_path not needed for ls)
756
+ sync_project = SyncProject(
757
+ name=project_data.name,
758
+ path=normalize_project_path(project_data.path),
759
+ )
760
+
761
+ # List files
762
+ files = project_ls(sync_project, bucket_name, path=path)
763
+
764
+ if files:
765
+ console.print(f"\n[bold]Files in {name}" + (f"/{path}" if path else "") + ":[/bold]")
766
+ for file in files:
767
+ console.print(f" {file}")
768
+ console.print(f"\n[dim]Total: {len(files)} files[/dim]")
769
+ else:
770
+ console.print(
771
+ f"[yellow]No files found in {name}" + (f"/{path}" if path else "") + "[/yellow]"
772
+ )
773
+
774
+ except Exception as e:
775
+ console.print(f"[red]Error: {e}[/red]")
776
+ raise typer.Exit(1)
777
+
778
+
779
+ @project_app.command("info")
780
+ def display_project_info(
781
+ name: str = typer.Argument(..., help="Name of the project"),
782
+ json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
783
+ ):
784
+ """Display detailed information and statistics about the current project."""
785
+ try:
786
+ # Get project info
787
+ info = asyncio.run(get_project_info(name))
788
+
789
+ if json_output:
790
+ # Convert to JSON and print
791
+ print(json.dumps(info.model_dump(), indent=2, default=str))
792
+ else:
793
+ # Project configuration section
794
+ console.print(
795
+ Panel(
796
+ f"Basic Memory version: [bold green]{info.system.version}[/bold green]\n"
797
+ f"[bold]Project:[/bold] {info.project_name}\n"
798
+ f"[bold]Path:[/bold] {info.project_path}\n"
799
+ f"[bold]Default Project:[/bold] {info.default_project}\n",
800
+ title="Basic Memory Project Info",
801
+ expand=False,
802
+ )
803
+ )
804
+
805
+ # Statistics section
806
+ stats_table = Table(title="Statistics")
807
+ stats_table.add_column("Metric", style="cyan")
808
+ stats_table.add_column("Count", style="green")
809
+
810
+ stats_table.add_row("Entities", str(info.statistics.total_entities))
811
+ stats_table.add_row("Observations", str(info.statistics.total_observations))
812
+ stats_table.add_row("Relations", str(info.statistics.total_relations))
813
+ stats_table.add_row(
814
+ "Unresolved Relations", str(info.statistics.total_unresolved_relations)
815
+ )
816
+ stats_table.add_row("Isolated Entities", str(info.statistics.isolated_entities))
817
+
818
+ console.print(stats_table)
819
+
820
+ # Entity types
821
+ if info.statistics.entity_types:
822
+ entity_types_table = Table(title="Entity Types")
823
+ entity_types_table.add_column("Type", style="blue")
824
+ entity_types_table.add_column("Count", style="green")
825
+
826
+ for entity_type, count in info.statistics.entity_types.items():
827
+ entity_types_table.add_row(entity_type, str(count))
828
+
829
+ console.print(entity_types_table)
830
+
831
+ # Most connected entities
832
+ if info.statistics.most_connected_entities: # pragma: no cover
833
+ connected_table = Table(title="Most Connected Entities")
834
+ connected_table.add_column("Title", style="blue")
835
+ connected_table.add_column("Permalink", style="cyan")
836
+ connected_table.add_column("Relations", style="green")
837
+
838
+ for entity in info.statistics.most_connected_entities:
839
+ connected_table.add_row(
840
+ entity["title"], entity["permalink"], str(entity["relation_count"])
841
+ )
842
+
843
+ console.print(connected_table)
844
+
845
+ # Recent activity
846
+ if info.activity.recently_updated: # pragma: no cover
847
+ recent_table = Table(title="Recent Activity")
848
+ recent_table.add_column("Title", style="blue")
849
+ recent_table.add_column("Type", style="cyan")
850
+ recent_table.add_column("Last Updated", style="green")
851
+
852
+ for entity in info.activity.recently_updated[:5]: # Show top 5
853
+ updated_at = (
854
+ datetime.fromisoformat(entity["updated_at"])
855
+ if isinstance(entity["updated_at"], str)
856
+ else entity["updated_at"]
857
+ )
858
+ recent_table.add_row(
859
+ entity["title"],
860
+ entity["entity_type"],
861
+ updated_at.strftime("%Y-%m-%d %H:%M"),
862
+ )
863
+
864
+ console.print(recent_table)
865
+
866
+ # Available projects
867
+ projects_table = Table(title="Available Projects")
868
+ projects_table.add_column("Name", style="blue")
869
+ projects_table.add_column("Path", style="cyan")
870
+ projects_table.add_column("Default", style="green")
871
+
872
+ for name, proj_info in info.available_projects.items():
873
+ is_default = name == info.default_project
874
+ project_path = proj_info["path"]
875
+ projects_table.add_row(name, project_path, "[X]" if is_default else "")
876
+
877
+ console.print(projects_table)
878
+
879
+ # Timestamp
880
+ current_time = (
881
+ datetime.fromisoformat(str(info.system.timestamp))
882
+ if isinstance(info.system.timestamp, str)
883
+ else info.system.timestamp
884
+ )
885
+ console.print(f"\nTimestamp: [cyan]{current_time.strftime('%Y-%m-%d %H:%M:%S')}[/cyan]")
886
+
887
+ except Exception as e: # pragma: no cover
888
+ typer.echo(f"Error getting project info: {e}", err=True)
889
+ raise typer.Exit(1)