basic-memory 0.14.4__py3-none-any.whl → 0.15.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (84) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/directory_router.py +23 -2
  5. basic_memory/api/routers/knowledge_router.py +25 -8
  6. basic_memory/api/routers/project_router.py +100 -4
  7. basic_memory/cli/app.py +9 -28
  8. basic_memory/cli/auth.py +277 -0
  9. basic_memory/cli/commands/cloud/__init__.py +5 -0
  10. basic_memory/cli/commands/cloud/api_client.py +112 -0
  11. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  12. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  13. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  14. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  15. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  16. basic_memory/cli/commands/command_utils.py +43 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +77 -60
  19. basic_memory/cli/commands/project.py +154 -152
  20. basic_memory/cli/commands/status.py +25 -22
  21. basic_memory/cli/commands/sync.py +45 -228
  22. basic_memory/cli/commands/tool.py +87 -16
  23. basic_memory/cli/main.py +1 -0
  24. basic_memory/config.py +131 -21
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +27 -8
  27. basic_memory/file_utils.py +37 -13
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/markdown/plugins.py +9 -7
  30. basic_memory/mcp/async_client.py +124 -14
  31. basic_memory/mcp/project_context.py +141 -0
  32. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  33. basic_memory/mcp/prompts/continue_conversation.py +17 -16
  34. basic_memory/mcp/prompts/recent_activity.py +116 -32
  35. basic_memory/mcp/prompts/search.py +13 -12
  36. basic_memory/mcp/prompts/utils.py +11 -4
  37. basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
  38. basic_memory/mcp/resources/project_info.py +27 -11
  39. basic_memory/mcp/server.py +0 -37
  40. basic_memory/mcp/tools/__init__.py +5 -6
  41. basic_memory/mcp/tools/build_context.py +67 -56
  42. basic_memory/mcp/tools/canvas.py +38 -26
  43. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  44. basic_memory/mcp/tools/delete_note.py +81 -47
  45. basic_memory/mcp/tools/edit_note.py +155 -138
  46. basic_memory/mcp/tools/list_directory.py +112 -99
  47. basic_memory/mcp/tools/move_note.py +181 -101
  48. basic_memory/mcp/tools/project_management.py +113 -277
  49. basic_memory/mcp/tools/read_content.py +91 -74
  50. basic_memory/mcp/tools/read_note.py +152 -115
  51. basic_memory/mcp/tools/recent_activity.py +471 -68
  52. basic_memory/mcp/tools/search.py +105 -92
  53. basic_memory/mcp/tools/sync_status.py +136 -130
  54. basic_memory/mcp/tools/utils.py +4 -0
  55. basic_memory/mcp/tools/view_note.py +44 -33
  56. basic_memory/mcp/tools/write_note.py +151 -90
  57. basic_memory/models/knowledge.py +12 -6
  58. basic_memory/models/project.py +6 -2
  59. basic_memory/repository/entity_repository.py +89 -82
  60. basic_memory/repository/relation_repository.py +13 -0
  61. basic_memory/repository/repository.py +18 -5
  62. basic_memory/repository/search_repository.py +46 -2
  63. basic_memory/schemas/__init__.py +6 -0
  64. basic_memory/schemas/base.py +39 -11
  65. basic_memory/schemas/cloud.py +46 -0
  66. basic_memory/schemas/memory.py +90 -21
  67. basic_memory/schemas/project_info.py +9 -10
  68. basic_memory/schemas/sync_report.py +48 -0
  69. basic_memory/services/context_service.py +25 -11
  70. basic_memory/services/directory_service.py +124 -3
  71. basic_memory/services/entity_service.py +100 -48
  72. basic_memory/services/initialization.py +30 -11
  73. basic_memory/services/project_service.py +101 -24
  74. basic_memory/services/search_service.py +16 -8
  75. basic_memory/sync/sync_service.py +173 -34
  76. basic_memory/sync/watch_service.py +101 -40
  77. basic_memory/utils.py +14 -4
  78. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
  79. basic_memory-0.15.1.dist-info/RECORD +146 -0
  80. basic_memory/mcp/project_session.py +0 -120
  81. basic_memory-0.14.4.dist-info/RECORD +0 -133
  82. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  83. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  84. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,242 +1,59 @@
1
1
  """Command module for basic-memory sync operations."""
2
2
 
3
3
  import asyncio
4
- from collections import defaultdict
5
- from dataclasses import dataclass
6
- from pathlib import Path
7
- from typing import List, Dict
4
+ from typing import Annotated, Optional
8
5
 
9
6
  import typer
10
- from loguru import logger
11
- from rich.console import Console
12
- from rich.tree import Tree
13
7
 
14
- from basic_memory import db
15
8
  from basic_memory.cli.app import app
16
- from basic_memory.config import ConfigManager, get_project_config
17
- from basic_memory.markdown import EntityParser
18
- from basic_memory.markdown.markdown_processor import MarkdownProcessor
19
- from basic_memory.models import Project
20
- from basic_memory.repository import (
21
- EntityRepository,
22
- ObservationRepository,
23
- RelationRepository,
24
- ProjectRepository,
25
- )
26
- from basic_memory.repository.search_repository import SearchRepository
27
- from basic_memory.services import EntityService, FileService
28
- from basic_memory.services.link_resolver import LinkResolver
29
- from basic_memory.services.search_service import SearchService
30
- from basic_memory.sync import SyncService
31
- from basic_memory.sync.sync_service import SyncReport
32
-
33
- console = Console()
34
-
35
-
36
- @dataclass
37
- class ValidationIssue:
38
- file_path: str
39
- error: str
40
-
41
-
42
- async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
43
- """Get sync service instance with all dependencies."""
44
-
45
- app_config = ConfigManager().config
46
- _, session_maker = await db.get_or_create_db(
47
- db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
48
- )
49
-
50
- project_path = Path(project.path)
51
- entity_parser = EntityParser(project_path)
52
- markdown_processor = MarkdownProcessor(entity_parser)
53
- file_service = FileService(project_path, markdown_processor)
54
-
55
- # Initialize repositories
56
- entity_repository = EntityRepository(session_maker, project_id=project.id)
57
- observation_repository = ObservationRepository(session_maker, project_id=project.id)
58
- relation_repository = RelationRepository(session_maker, project_id=project.id)
59
- search_repository = SearchRepository(session_maker, project_id=project.id)
60
-
61
- # Initialize services
62
- search_service = SearchService(search_repository, entity_repository, file_service)
63
- link_resolver = LinkResolver(entity_repository, search_service)
64
-
65
- # Initialize services
66
- entity_service = EntityService(
67
- entity_parser,
68
- entity_repository,
69
- observation_repository,
70
- relation_repository,
71
- file_service,
72
- link_resolver,
73
- )
74
-
75
- # Create sync service
76
- sync_service = SyncService(
77
- app_config=app_config,
78
- entity_service=entity_service,
79
- entity_parser=entity_parser,
80
- entity_repository=entity_repository,
81
- relation_repository=relation_repository,
82
- search_service=search_service,
83
- file_service=file_service,
84
- )
85
-
86
- return sync_service
87
-
88
-
89
- def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[ValidationIssue]]:
90
- """Group validation issues by directory."""
91
- grouped = defaultdict(list)
92
- for issue in issues:
93
- dir_name = Path(issue.file_path).parent.name
94
- grouped[dir_name].append(issue)
95
- return dict(grouped)
96
-
97
-
98
- def display_sync_summary(knowledge: SyncReport):
99
- """Display a one-line summary of sync changes."""
100
- config = get_project_config()
101
- total_changes = knowledge.total
102
- project_name = config.project
103
-
104
- if total_changes == 0:
105
- console.print(f"[green]Project '{project_name}': Everything up to date[/green]")
106
- return
107
-
108
- # Format as: "Synced X files (A new, B modified, C moved, D deleted)"
109
- changes = []
110
- new_count = len(knowledge.new)
111
- mod_count = len(knowledge.modified)
112
- move_count = len(knowledge.moves)
113
- del_count = len(knowledge.deleted)
114
-
115
- if new_count:
116
- changes.append(f"[green]{new_count} new[/green]")
117
- if mod_count:
118
- changes.append(f"[yellow]{mod_count} modified[/yellow]")
119
- if move_count:
120
- changes.append(f"[blue]{move_count} moved[/blue]")
121
- if del_count:
122
- changes.append(f"[red]{del_count} deleted[/red]")
123
-
124
- console.print(f"Project '{project_name}': Synced {total_changes} files ({', '.join(changes)})")
125
-
126
-
127
- def display_detailed_sync_results(knowledge: SyncReport):
128
- """Display detailed sync results with trees."""
129
- config = get_project_config()
130
- project_name = config.project
131
-
132
- if knowledge.total == 0:
133
- console.print(f"\n[green]Project '{project_name}': Everything up to date[/green]")
134
- return
135
-
136
- console.print(f"\n[bold]Sync Results for Project '{project_name}'[/bold]")
137
-
138
- if knowledge.total > 0:
139
- knowledge_tree = Tree("[bold]Knowledge Files[/bold]")
140
- if knowledge.new:
141
- created = knowledge_tree.add("[green]Created[/green]")
142
- for path in sorted(knowledge.new):
143
- checksum = knowledge.checksums.get(path, "")
144
- created.add(f"[green]{path}[/green] ({checksum[:8]})")
145
- if knowledge.modified:
146
- modified = knowledge_tree.add("[yellow]Modified[/yellow]")
147
- for path in sorted(knowledge.modified):
148
- checksum = knowledge.checksums.get(path, "")
149
- modified.add(f"[yellow]{path}[/yellow] ({checksum[:8]})")
150
- if knowledge.moves:
151
- moved = knowledge_tree.add("[blue]Moved[/blue]")
152
- for old_path, new_path in sorted(knowledge.moves.items()):
153
- checksum = knowledge.checksums.get(new_path, "")
154
- moved.add(f"[blue]{old_path}[/blue] → [blue]{new_path}[/blue] ({checksum[:8]})")
155
- if knowledge.deleted:
156
- deleted = knowledge_tree.add("[red]Deleted[/red]")
157
- for path in sorted(knowledge.deleted):
158
- deleted.add(f"[red]{path}[/red]")
159
- console.print(knowledge_tree)
160
-
161
-
162
- async def run_sync(verbose: bool = False):
163
- """Run sync operation."""
164
- app_config = ConfigManager().config
165
- config = get_project_config()
166
-
167
- _, session_maker = await db.get_or_create_db(
168
- db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
169
- )
170
- project_repository = ProjectRepository(session_maker)
171
- project = await project_repository.get_by_name(config.project)
172
- if not project: # pragma: no cover
173
- raise Exception(f"Project '{config.project}' not found")
174
-
175
- import time
176
-
177
- start_time = time.time()
178
-
179
- logger.info(
180
- "Sync command started",
181
- project=config.project,
182
- verbose=verbose,
183
- directory=str(config.home),
184
- )
185
-
186
- sync_service = await get_sync_service(project)
187
-
188
- logger.info("Running one-time sync")
189
- knowledge_changes = await sync_service.sync(config.home, project_name=project.name)
190
-
191
- # Log results
192
- duration_ms = int((time.time() - start_time) * 1000)
193
- logger.info(
194
- "Sync command completed",
195
- project=config.project,
196
- total_changes=knowledge_changes.total,
197
- new_files=len(knowledge_changes.new),
198
- modified_files=len(knowledge_changes.modified),
199
- deleted_files=len(knowledge_changes.deleted),
200
- moved_files=len(knowledge_changes.moves),
201
- duration_ms=duration_ms,
202
- )
203
-
204
- # Display results
205
- if verbose:
206
- display_detailed_sync_results(knowledge_changes)
207
- else:
208
- display_sync_summary(knowledge_changes) # pragma: no cover
9
+ from basic_memory.cli.commands.command_utils import run_sync
10
+ from basic_memory.config import ConfigManager
209
11
 
210
12
 
211
13
  @app.command()
212
14
  def sync(
213
- verbose: bool = typer.Option(
214
- False,
215
- "--verbose",
216
- "-v",
217
- help="Show detailed sync information.",
218
- ),
15
+ project: Annotated[
16
+ Optional[str],
17
+ typer.Option(help="The project name."),
18
+ ] = None,
19
+ watch: Annotated[
20
+ bool,
21
+ typer.Option("--watch", help="Run continuous sync (cloud mode only)"),
22
+ ] = False,
23
+ interval: Annotated[
24
+ int,
25
+ typer.Option("--interval", help="Sync interval in seconds for watch mode (default: 60)"),
26
+ ] = 60,
219
27
  ) -> None:
220
- """Sync knowledge files with the database."""
221
- config = get_project_config()
222
-
223
- try:
224
- # Show which project we're syncing
225
- typer.echo(f"Syncing project: {config.project}")
226
- typer.echo(f"Project path: {config.home}")
227
-
228
- # Run sync
229
- asyncio.run(run_sync(verbose=verbose))
230
-
231
- except Exception as e: # pragma: no cover
232
- if not isinstance(e, typer.Exit):
233
- logger.exception(
234
- "Sync command failed",
235
- f"project={config.project},"
236
- f"error={str(e)},"
237
- f"error_type={type(e).__name__},"
238
- f"directory={str(config.home)}",
28
+ """Sync knowledge files with the database.
29
+
30
+ In local mode: Scans filesystem and updates database.
31
+ In cloud mode: Runs bidirectional file sync (bisync) then updates database.
32
+
33
+ Examples:
34
+ bm sync # One-time sync
35
+ bm sync --watch # Continuous sync every 60s
36
+ bm sync --watch --interval 30 # Continuous sync every 30s
37
+ """
38
+ config = ConfigManager().config
39
+
40
+ if config.cloud_mode_enabled:
41
+ # Cloud mode: run bisync which includes database sync
42
+ from basic_memory.cli.commands.cloud.bisync_commands import run_bisync, run_bisync_watch
43
+
44
+ try:
45
+ if watch:
46
+ run_bisync_watch(interval_seconds=interval)
47
+ else:
48
+ run_bisync()
49
+ except Exception:
50
+ raise typer.Exit(1)
51
+ else:
52
+ # Local mode: just database sync
53
+ if watch:
54
+ typer.echo(
55
+ "Error: --watch is only available in cloud mode. Run 'bm cloud login' first."
239
56
  )
240
- typer.echo(f"Error during sync: {e}", err=True)
241
57
  raise typer.Exit(1)
242
- raise
58
+
59
+ asyncio.run(run_sync(project))
@@ -9,6 +9,7 @@ from loguru import logger
9
9
  from rich import print as rprint
10
10
 
11
11
  from basic_memory.cli.app import app
12
+ from basic_memory.config import ConfigManager
12
13
 
13
14
  # Import prompts
14
15
  from basic_memory.mcp.prompts.continue_conversation import (
@@ -34,6 +35,12 @@ app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
34
35
  def write_note(
35
36
  title: Annotated[str, typer.Option(help="The title of the note")],
36
37
  folder: Annotated[str, typer.Option(help="The folder to create the note in")],
38
+ project: Annotated[
39
+ Optional[str],
40
+ typer.Option(
41
+ help="The project to write to. If not provided, the default project will be used."
42
+ ),
43
+ ] = None,
37
44
  content: Annotated[
38
45
  Optional[str],
39
46
  typer.Option(
@@ -90,7 +97,19 @@ def write_note(
90
97
  typer.echo("Empty content provided. Please provide non-empty content.", err=True)
91
98
  raise typer.Exit(1)
92
99
 
93
- note = asyncio.run(mcp_write_note.fn(title, content, folder, tags))
100
+ # look for the project in the config
101
+ config_manager = ConfigManager()
102
+ project_name = None
103
+ if project is not None:
104
+ project_name, _ = config_manager.get_project(project)
105
+ if not project_name:
106
+ typer.echo(f"No project found named: {project}", err=True)
107
+ raise typer.Exit(1)
108
+
109
+ # use the project name, or the default from the config
110
+ project_name = project_name or config_manager.default_project
111
+
112
+ note = asyncio.run(mcp_write_note.fn(title, content, folder, project_name, tags))
94
113
  rprint(note)
95
114
  except Exception as e: # pragma: no cover
96
115
  if not isinstance(e, typer.Exit):
@@ -100,10 +119,33 @@ def write_note(
100
119
 
101
120
 
102
121
  @tool_app.command()
103
- def read_note(identifier: str, page: int = 1, page_size: int = 10):
122
+ def read_note(
123
+ identifier: str,
124
+ project: Annotated[
125
+ Optional[str],
126
+ typer.Option(
127
+ help="The project to use for the note. If not provided, the default project will be used."
128
+ ),
129
+ ] = None,
130
+ page: int = 1,
131
+ page_size: int = 10,
132
+ ):
104
133
  """Read a markdown note from the knowledge base."""
134
+
135
+ # look for the project in the config
136
+ config_manager = ConfigManager()
137
+ project_name = None
138
+ if project is not None:
139
+ project_name, _ = config_manager.get_project(project)
140
+ if not project_name:
141
+ typer.echo(f"No project found named: {project}", err=True)
142
+ raise typer.Exit(1)
143
+
144
+ # use the project name, or the default from the config
145
+ project_name = project_name or config_manager.default_project
146
+
105
147
  try:
106
- note = asyncio.run(mcp_read_note.fn(identifier, page, page_size))
148
+ note = asyncio.run(mcp_read_note.fn(identifier, project_name, page, page_size))
107
149
  rprint(note)
108
150
  except Exception as e: # pragma: no cover
109
151
  if not isinstance(e, typer.Exit):
@@ -115,6 +157,10 @@ def read_note(identifier: str, page: int = 1, page_size: int = 10):
115
157
  @tool_app.command()
116
158
  def build_context(
117
159
  url: MemoryUrl,
160
+ project: Annotated[
161
+ Optional[str],
162
+ typer.Option(help="The project to use. If not provided, the default project will be used."),
163
+ ] = None,
118
164
  depth: Optional[int] = 1,
119
165
  timeframe: Optional[TimeFrame] = "7d",
120
166
  page: int = 1,
@@ -122,9 +168,23 @@ def build_context(
122
168
  max_related: int = 10,
123
169
  ):
124
170
  """Get context needed to continue a discussion."""
171
+
172
+ # look for the project in the config
173
+ config_manager = ConfigManager()
174
+ project_name = None
175
+ if project is not None:
176
+ project_name, _ = config_manager.get_project(project)
177
+ if not project_name:
178
+ typer.echo(f"No project found named: {project}", err=True)
179
+ raise typer.Exit(1)
180
+
181
+ # use the project name, or the default from the config
182
+ project_name = project_name or config_manager.default_project
183
+
125
184
  try:
126
185
  context = asyncio.run(
127
186
  mcp_build_context.fn(
187
+ project=project_name,
128
188
  url=url,
129
189
  depth=depth,
130
190
  timeframe=timeframe,
@@ -150,30 +210,21 @@ def recent_activity(
150
210
  type: Annotated[Optional[List[SearchItemType]], typer.Option()] = None,
151
211
  depth: Optional[int] = 1,
152
212
  timeframe: Optional[TimeFrame] = "7d",
153
- page: int = 1,
154
- page_size: int = 10,
155
- max_related: int = 10,
156
213
  ):
157
214
  """Get recent activity across the knowledge base."""
158
215
  try:
159
- context = asyncio.run(
216
+ result = asyncio.run(
160
217
  mcp_recent_activity.fn(
161
218
  type=type, # pyright: ignore [reportArgumentType]
162
219
  depth=depth,
163
220
  timeframe=timeframe,
164
- page=page,
165
- page_size=page_size,
166
- max_related=max_related,
167
221
  )
168
222
  )
169
- # Use json module for more controlled serialization
170
- import json
171
-
172
- context_dict = context.model_dump(exclude_none=True)
173
- print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
223
+ # The tool now returns a formatted string directly
224
+ print(result)
174
225
  except Exception as e: # pragma: no cover
175
226
  if not isinstance(e, typer.Exit):
176
- typer.echo(f"Error during build_context: {e}", err=True)
227
+ typer.echo(f"Error during recent_activity: {e}", err=True)
177
228
  raise typer.Exit(1)
178
229
  raise
179
230
 
@@ -183,6 +234,12 @@ def search_notes(
183
234
  query: str,
184
235
  permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
185
236
  title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
237
+ project: Annotated[
238
+ Optional[str],
239
+ typer.Option(
240
+ help="The project to use for the note. If not provided, the default project will be used."
241
+ ),
242
+ ] = None,
186
243
  after_date: Annotated[
187
244
  Optional[str],
188
245
  typer.Option("--after_date", help="Search results after date, eg. '2d', '1 week'"),
@@ -191,6 +248,19 @@ def search_notes(
191
248
  page_size: int = 10,
192
249
  ):
193
250
  """Search across all content in the knowledge base."""
251
+
252
+ # look for the project in the config
253
+ config_manager = ConfigManager()
254
+ project_name = None
255
+ if project is not None:
256
+ project_name, _ = config_manager.get_project(project)
257
+ if not project_name:
258
+ typer.echo(f"No project found named: {project}", err=True)
259
+ raise typer.Exit(1)
260
+
261
+ # use the project name, or the default from the config
262
+ project_name = project_name or config_manager.default_project
263
+
194
264
  if permalink and title: # pragma: no cover
195
265
  print("Cannot search both permalink and title")
196
266
  raise typer.Abort()
@@ -212,6 +282,7 @@ def search_notes(
212
282
  results = asyncio.run(
213
283
  mcp_search.fn(
214
284
  query,
285
+ project_name,
215
286
  search_type=search_type,
216
287
  page=page,
217
288
  after_date=after_date,
basic_memory/cli/main.py CHANGED
@@ -4,6 +4,7 @@ from basic_memory.cli.app import app # pragma: no cover
4
4
 
5
5
  # Register commands
6
6
  from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
7
+ cloud,
7
8
  db,
8
9
  import_chatgpt,
9
10
  import_claude_conversations,