basic-memory 0.14.3__py3-none-any.whl → 0.15.0__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 (90) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/knowledge_router.py +25 -8
  5. basic_memory/api/routers/project_router.py +99 -4
  6. basic_memory/api/routers/resource_router.py +3 -3
  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 +60 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +16 -4
  19. basic_memory/cli/commands/project.py +141 -145
  20. basic_memory/cli/commands/status.py +34 -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 +96 -20
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +20 -3
  27. basic_memory/file_utils.py +89 -0
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/importers/chatgpt_importer.py +1 -1
  30. basic_memory/importers/utils.py +2 -2
  31. basic_memory/markdown/entity_parser.py +2 -2
  32. basic_memory/markdown/markdown_processor.py +2 -2
  33. basic_memory/markdown/plugins.py +39 -21
  34. basic_memory/markdown/utils.py +1 -1
  35. basic_memory/mcp/async_client.py +22 -10
  36. basic_memory/mcp/project_context.py +141 -0
  37. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  38. basic_memory/mcp/prompts/continue_conversation.py +1 -1
  39. basic_memory/mcp/prompts/recent_activity.py +116 -32
  40. basic_memory/mcp/prompts/search.py +1 -1
  41. basic_memory/mcp/prompts/utils.py +11 -4
  42. basic_memory/mcp/resources/ai_assistant_guide.md +179 -41
  43. basic_memory/mcp/resources/project_info.py +20 -6
  44. basic_memory/mcp/server.py +0 -37
  45. basic_memory/mcp/tools/__init__.py +5 -6
  46. basic_memory/mcp/tools/build_context.py +39 -19
  47. basic_memory/mcp/tools/canvas.py +19 -8
  48. basic_memory/mcp/tools/chatgpt_tools.py +178 -0
  49. basic_memory/mcp/tools/delete_note.py +67 -34
  50. basic_memory/mcp/tools/edit_note.py +55 -39
  51. basic_memory/mcp/tools/headers.py +44 -0
  52. basic_memory/mcp/tools/list_directory.py +18 -8
  53. basic_memory/mcp/tools/move_note.py +119 -41
  54. basic_memory/mcp/tools/project_management.py +77 -229
  55. basic_memory/mcp/tools/read_content.py +28 -12
  56. basic_memory/mcp/tools/read_note.py +97 -57
  57. basic_memory/mcp/tools/recent_activity.py +441 -42
  58. basic_memory/mcp/tools/search.py +82 -70
  59. basic_memory/mcp/tools/sync_status.py +5 -4
  60. basic_memory/mcp/tools/utils.py +19 -0
  61. basic_memory/mcp/tools/view_note.py +31 -6
  62. basic_memory/mcp/tools/write_note.py +65 -14
  63. basic_memory/models/knowledge.py +19 -2
  64. basic_memory/models/project.py +6 -2
  65. basic_memory/repository/entity_repository.py +31 -84
  66. basic_memory/repository/project_repository.py +1 -1
  67. basic_memory/repository/relation_repository.py +13 -0
  68. basic_memory/repository/repository.py +2 -2
  69. basic_memory/repository/search_repository.py +9 -3
  70. basic_memory/schemas/__init__.py +6 -0
  71. basic_memory/schemas/base.py +70 -12
  72. basic_memory/schemas/cloud.py +46 -0
  73. basic_memory/schemas/memory.py +99 -18
  74. basic_memory/schemas/project_info.py +9 -10
  75. basic_memory/schemas/sync_report.py +48 -0
  76. basic_memory/services/context_service.py +35 -11
  77. basic_memory/services/directory_service.py +7 -0
  78. basic_memory/services/entity_service.py +82 -52
  79. basic_memory/services/initialization.py +30 -11
  80. basic_memory/services/project_service.py +23 -33
  81. basic_memory/sync/sync_service.py +148 -24
  82. basic_memory/sync/watch_service.py +128 -44
  83. basic_memory/utils.py +181 -109
  84. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/METADATA +26 -96
  85. basic_memory-0.15.0.dist-info/RECORD +147 -0
  86. basic_memory/mcp/project_session.py +0 -120
  87. basic_memory-0.14.3.dist-info/RECORD +0 -132
  88. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/WHEEL +0 -0
  89. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/entry_points.txt +0 -0
  90. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.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,
basic_memory/config.py CHANGED
@@ -46,7 +46,7 @@ class BasicMemoryConfig(BaseSettings):
46
46
 
47
47
  projects: Dict[str, str] = Field(
48
48
  default_factory=lambda: {
49
- "main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
49
+ "main": Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")).as_posix()
50
50
  },
51
51
  description="Mapping of project names to their filesystem paths",
52
52
  )
@@ -54,6 +54,10 @@ class BasicMemoryConfig(BaseSettings):
54
54
  default="main",
55
55
  description="Name of the default project to use",
56
56
  )
57
+ default_project_mode: bool = Field(
58
+ default=False,
59
+ description="When True, MCP tools automatically use default_project when no project parameter is specified. Enables simplified UX for single-project workflows.",
60
+ )
57
61
 
58
62
  # overridden by ~/.basic-memory/config.json
59
63
  log_level: str = "INFO"
@@ -63,6 +67,10 @@ class BasicMemoryConfig(BaseSettings):
63
67
  default=1000, description="Milliseconds to wait after changes before syncing", gt=0
64
68
  )
65
69
 
70
+ watch_project_reload_interval: int = Field(
71
+ default=30, description="Seconds between reloading project list in watch service", gt=0
72
+ )
73
+
66
74
  # update permalinks on move
67
75
  update_permalinks_on_move: bool = Field(
68
76
  default=False,
@@ -74,17 +82,83 @@ class BasicMemoryConfig(BaseSettings):
74
82
  description="Whether to sync changes in real time. default (True)",
75
83
  )
76
84
 
85
+ sync_thread_pool_size: int = Field(
86
+ default=4,
87
+ description="Size of thread pool for file I/O operations in sync service",
88
+ gt=0,
89
+ )
90
+
91
+ kebab_filenames: bool = Field(
92
+ default=False,
93
+ description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks",
94
+ )
95
+
96
+ disable_permalinks: bool = Field(
97
+ default=False,
98
+ description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.",
99
+ )
100
+
101
+ skip_initialization_sync: bool = Field(
102
+ default=False,
103
+ description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
104
+ )
105
+
77
106
  # API connection configuration
78
107
  api_url: Optional[str] = Field(
79
108
  default=None,
80
109
  description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.",
81
110
  )
82
111
 
112
+ # Cloud configuration
113
+ cloud_client_id: str = Field(
114
+ default="client_01K6KWQPW6J1M8VV7R3TZP5A6M",
115
+ description="OAuth client ID for Basic Memory Cloud",
116
+ )
117
+
118
+ cloud_domain: str = Field(
119
+ default="https://eloquent-lotus-05.authkit.app",
120
+ description="AuthKit domain for Basic Memory Cloud",
121
+ )
122
+
123
+ cloud_host: str = Field(
124
+ default_factory=lambda: os.getenv(
125
+ "BASIC_MEMORY_CLOUD_HOST", "https://cloud.basicmemory.com"
126
+ ),
127
+ description="Basic Memory Cloud host URL",
128
+ )
129
+
130
+ cloud_mode: bool = Field(
131
+ default=False,
132
+ description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
133
+ )
134
+
135
+ @property
136
+ def cloud_mode_enabled(self) -> bool:
137
+ """Check if cloud mode is enabled.
138
+
139
+ Priority:
140
+ 1. BASIC_MEMORY_CLOUD_MODE environment variable
141
+ 2. Config file value (cloud_mode)
142
+ """
143
+ env_value = os.environ.get("BASIC_MEMORY_CLOUD_MODE", "").lower()
144
+ if env_value in ("true", "1", "yes"):
145
+ return True
146
+ elif env_value in ("false", "0", "no"):
147
+ return False
148
+ # Fall back to config file value
149
+ return self.cloud_mode
150
+
151
+ bisync_config: Dict[str, Any] = Field(
152
+ default_factory=lambda: {
153
+ "profile": "balanced",
154
+ "sync_dir": str(Path.home() / "basic-memory-cloud-sync"),
155
+ },
156
+ description="Bisync configuration for cloud sync",
157
+ )
158
+
83
159
  model_config = SettingsConfigDict(
84
160
  env_prefix="BASIC_MEMORY_",
85
161
  extra="ignore",
86
- env_file=".env",
87
- env_file_encoding="utf-8",
88
162
  )
89
163
 
90
164
  def get_project_path(self, project_name: Optional[str] = None) -> Path: # pragma: no cover
@@ -100,9 +174,9 @@ class BasicMemoryConfig(BaseSettings):
100
174
  """Ensure configuration is valid after initialization."""
101
175
  # Ensure main project exists
102
176
  if "main" not in self.projects: # pragma: no cover
103
- self.projects["main"] = str(
177
+ self.projects["main"] = (
104
178
  Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
105
- )
179
+ ).as_posix()
106
180
 
107
181
  # Ensure default project is valid
108
182
  if self.default_project not in self.projects: # pragma: no cover
@@ -153,6 +227,10 @@ class BasicMemoryConfig(BaseSettings):
153
227
  raise e
154
228
  return v
155
229
 
230
+ @property
231
+ def data_dir_path(self):
232
+ return Path.home() / DATA_DIR_NAME
233
+
156
234
 
157
235
  class ConfigManager:
158
236
  """Manages Basic Memory configuration."""
@@ -215,7 +293,7 @@ class ConfigManager:
215
293
 
216
294
  # Load config, modify it, and save it
217
295
  config = self.load_config()
218
- config.projects[name] = str(project_path)
296
+ config.projects[name] = project_path.as_posix()
219
297
  self.save_config(config)
220
298
  return ProjectConfig(name=name, home=project_path)
221
299
 
@@ -242,7 +320,7 @@ class ConfigManager:
242
320
 
243
321
  # Load config, modify, and save
244
322
  config = self.load_config()
245
- config.default_project = name
323
+ config.default_project = project_name
246
324
  self.save_config(config)
247
325
 
248
326
  def get_project(self, name: str) -> Tuple[str, str] | Tuple[None, None]:
@@ -271,7 +349,7 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
271
349
  os_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
272
350
  if os_project_name: # pragma: no cover
273
351
  logger.warning(
274
- f"BASIC_MEMORY_PROJECT is not supported anymore. Use the --project flag or set the default project in the config instead. Setting default project to {os_project_name}"
352
+ f"BASIC_MEMORY_PROJECT is not supported anymore. Set the default project in the config instead. Setting default project to {os_project_name}"
275
353
  )
276
354
  actual_project_name = project_name
277
355
  # if the project_name is passed in, use it
@@ -302,15 +380,6 @@ def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None
302
380
  logger.error(f"Failed to save config: {e}")
303
381
 
304
382
 
305
- def update_current_project(project_name: str) -> None:
306
- """Update the global config to use a different project.
307
-
308
- This is used by the CLI when --project flag is specified.
309
- """
310
- global config
311
- config = get_project_config(project_name) # pragma: no cover
312
-
313
-
314
383
  # setup logging to a single log file in user home directory
315
384
  user_home = Path.home()
316
385
  log_dir = user_home / DATA_DIR_NAME
@@ -351,15 +420,22 @@ def setup_basic_memory_logging(): # pragma: no cover
351
420
  # print("Skipping duplicate logging setup")
352
421
  return
353
422
 
354
- # Check for console logging environment variable
355
- console_logging = os.getenv("BASIC_MEMORY_CONSOLE_LOGGING", "false").lower() == "true"
423
+ # Check for console logging environment variable - accept more truthy values
424
+ console_logging_env = os.getenv("BASIC_MEMORY_CONSOLE_LOGGING", "false").lower()
425
+ console_logging = console_logging_env in ("true", "1", "yes", "on")
426
+
427
+ # Check for log level environment variable first, fall back to config
428
+ log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL")
429
+ if not log_level:
430
+ config_manager = ConfigManager()
431
+ log_level = config_manager.config.log_level
356
432
 
357
433
  config_manager = ConfigManager()
358
434
  config = get_project_config()
359
435
  setup_logging(
360
436
  env=config_manager.config.env,
361
437
  home_dir=user_home, # Use user home for logs
362
- log_level=config_manager.config.log_level,
438
+ log_level=log_level,
363
439
  log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
364
440
  console=console_logging,
365
441
  )