basic-memory 0.12.3__py3-none-any.whl → 0.13.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 (116) hide show
  1. basic_memory/__init__.py +2 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  5. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
  6. basic_memory/api/app.py +43 -13
  7. basic_memory/api/routers/__init__.py +4 -2
  8. basic_memory/api/routers/directory_router.py +63 -0
  9. basic_memory/api/routers/importer_router.py +152 -0
  10. basic_memory/api/routers/knowledge_router.py +139 -37
  11. basic_memory/api/routers/management_router.py +78 -0
  12. basic_memory/api/routers/memory_router.py +6 -62
  13. basic_memory/api/routers/project_router.py +234 -0
  14. basic_memory/api/routers/prompt_router.py +260 -0
  15. basic_memory/api/routers/search_router.py +3 -21
  16. basic_memory/api/routers/utils.py +130 -0
  17. basic_memory/api/template_loader.py +292 -0
  18. basic_memory/cli/app.py +20 -21
  19. basic_memory/cli/commands/__init__.py +2 -1
  20. basic_memory/cli/commands/auth.py +136 -0
  21. basic_memory/cli/commands/db.py +3 -3
  22. basic_memory/cli/commands/import_chatgpt.py +31 -207
  23. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  24. basic_memory/cli/commands/import_claude_projects.py +33 -143
  25. basic_memory/cli/commands/import_memory_json.py +26 -83
  26. basic_memory/cli/commands/mcp.py +71 -18
  27. basic_memory/cli/commands/project.py +102 -70
  28. basic_memory/cli/commands/status.py +19 -9
  29. basic_memory/cli/commands/sync.py +44 -58
  30. basic_memory/cli/commands/tool.py +6 -6
  31. basic_memory/cli/main.py +1 -5
  32. basic_memory/config.py +143 -87
  33. basic_memory/db.py +6 -4
  34. basic_memory/deps.py +227 -30
  35. basic_memory/importers/__init__.py +27 -0
  36. basic_memory/importers/base.py +79 -0
  37. basic_memory/importers/chatgpt_importer.py +222 -0
  38. basic_memory/importers/claude_conversations_importer.py +172 -0
  39. basic_memory/importers/claude_projects_importer.py +148 -0
  40. basic_memory/importers/memory_json_importer.py +93 -0
  41. basic_memory/importers/utils.py +58 -0
  42. basic_memory/markdown/entity_parser.py +5 -2
  43. basic_memory/mcp/auth_provider.py +270 -0
  44. basic_memory/mcp/external_auth_provider.py +321 -0
  45. basic_memory/mcp/project_session.py +103 -0
  46. basic_memory/mcp/prompts/__init__.py +2 -0
  47. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  48. basic_memory/mcp/prompts/recent_activity.py +20 -4
  49. basic_memory/mcp/prompts/search.py +14 -140
  50. basic_memory/mcp/prompts/sync_status.py +116 -0
  51. basic_memory/mcp/prompts/utils.py +3 -3
  52. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  53. basic_memory/mcp/server.py +86 -13
  54. basic_memory/mcp/supabase_auth_provider.py +463 -0
  55. basic_memory/mcp/tools/__init__.py +24 -0
  56. basic_memory/mcp/tools/build_context.py +43 -8
  57. basic_memory/mcp/tools/canvas.py +17 -3
  58. basic_memory/mcp/tools/delete_note.py +168 -5
  59. basic_memory/mcp/tools/edit_note.py +303 -0
  60. basic_memory/mcp/tools/list_directory.py +154 -0
  61. basic_memory/mcp/tools/move_note.py +299 -0
  62. basic_memory/mcp/tools/project_management.py +332 -0
  63. basic_memory/mcp/tools/read_content.py +15 -6
  64. basic_memory/mcp/tools/read_note.py +26 -7
  65. basic_memory/mcp/tools/recent_activity.py +11 -2
  66. basic_memory/mcp/tools/search.py +189 -8
  67. basic_memory/mcp/tools/sync_status.py +254 -0
  68. basic_memory/mcp/tools/utils.py +184 -12
  69. basic_memory/mcp/tools/view_note.py +66 -0
  70. basic_memory/mcp/tools/write_note.py +24 -17
  71. basic_memory/models/__init__.py +3 -2
  72. basic_memory/models/knowledge.py +16 -4
  73. basic_memory/models/project.py +78 -0
  74. basic_memory/models/search.py +8 -5
  75. basic_memory/repository/__init__.py +2 -0
  76. basic_memory/repository/entity_repository.py +8 -3
  77. basic_memory/repository/observation_repository.py +35 -3
  78. basic_memory/repository/project_info_repository.py +3 -2
  79. basic_memory/repository/project_repository.py +85 -0
  80. basic_memory/repository/relation_repository.py +8 -2
  81. basic_memory/repository/repository.py +107 -15
  82. basic_memory/repository/search_repository.py +192 -54
  83. basic_memory/schemas/__init__.py +6 -0
  84. basic_memory/schemas/base.py +33 -5
  85. basic_memory/schemas/directory.py +30 -0
  86. basic_memory/schemas/importer.py +34 -0
  87. basic_memory/schemas/memory.py +84 -13
  88. basic_memory/schemas/project_info.py +112 -2
  89. basic_memory/schemas/prompt.py +90 -0
  90. basic_memory/schemas/request.py +56 -2
  91. basic_memory/schemas/search.py +1 -1
  92. basic_memory/services/__init__.py +2 -1
  93. basic_memory/services/context_service.py +208 -95
  94. basic_memory/services/directory_service.py +167 -0
  95. basic_memory/services/entity_service.py +399 -6
  96. basic_memory/services/exceptions.py +6 -0
  97. basic_memory/services/file_service.py +14 -15
  98. basic_memory/services/initialization.py +170 -66
  99. basic_memory/services/link_resolver.py +35 -12
  100. basic_memory/services/migration_service.py +168 -0
  101. basic_memory/services/project_service.py +671 -0
  102. basic_memory/services/search_service.py +77 -2
  103. basic_memory/services/sync_status_service.py +181 -0
  104. basic_memory/sync/background_sync.py +25 -0
  105. basic_memory/sync/sync_service.py +102 -21
  106. basic_memory/sync/watch_service.py +63 -39
  107. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  108. basic_memory/templates/prompts/search.hbs +101 -0
  109. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/METADATA +24 -2
  110. basic_memory-0.13.0.dist-info/RECORD +138 -0
  111. basic_memory/api/routers/project_info_router.py +0 -274
  112. basic_memory/mcp/main.py +0 -24
  113. basic_memory-0.12.3.dist-info/RECORD +0 -100
  114. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
  115. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
  116. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -16,10 +16,12 @@ from basic_memory.cli.app import app
16
16
  from basic_memory.config import config
17
17
  from basic_memory.markdown import EntityParser
18
18
  from basic_memory.markdown.markdown_processor import MarkdownProcessor
19
+ from basic_memory.models import Project
19
20
  from basic_memory.repository import (
20
21
  EntityRepository,
21
22
  ObservationRepository,
22
23
  RelationRepository,
24
+ ProjectRepository,
23
25
  )
24
26
  from basic_memory.repository.search_repository import SearchRepository
25
27
  from basic_memory.services import EntityService, FileService
@@ -27,7 +29,7 @@ from basic_memory.services.link_resolver import LinkResolver
27
29
  from basic_memory.services.search_service import SearchService
28
30
  from basic_memory.sync import SyncService
29
31
  from basic_memory.sync.sync_service import SyncReport
30
- from basic_memory.sync.watch_service import WatchService
32
+ from basic_memory.config import app_config
31
33
 
32
34
  console = Console()
33
35
 
@@ -38,21 +40,22 @@ class ValidationIssue:
38
40
  error: str
39
41
 
40
42
 
41
- async def get_sync_service(): # pragma: no cover
43
+ async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
42
44
  """Get sync service instance with all dependencies."""
43
45
  _, session_maker = await db.get_or_create_db(
44
- db_path=config.database_path, db_type=db.DatabaseType.FILESYSTEM
46
+ db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
45
47
  )
46
48
 
47
- entity_parser = EntityParser(config.home)
49
+ project_path = Path(project.path)
50
+ entity_parser = EntityParser(project_path)
48
51
  markdown_processor = MarkdownProcessor(entity_parser)
49
- file_service = FileService(config.home, markdown_processor)
52
+ file_service = FileService(project_path, markdown_processor)
50
53
 
51
54
  # Initialize repositories
52
- entity_repository = EntityRepository(session_maker)
53
- observation_repository = ObservationRepository(session_maker)
54
- relation_repository = RelationRepository(session_maker)
55
- search_repository = SearchRepository(session_maker)
55
+ entity_repository = EntityRepository(session_maker, project_id=project.id)
56
+ observation_repository = ObservationRepository(session_maker, project_id=project.id)
57
+ relation_repository = RelationRepository(session_maker, project_id=project.id)
58
+ search_repository = SearchRepository(session_maker, project_id=project.id)
56
59
 
57
60
  # Initialize services
58
61
  search_service = SearchService(search_repository, entity_repository, file_service)
@@ -70,7 +73,7 @@ async def get_sync_service(): # pragma: no cover
70
73
 
71
74
  # Create sync service
72
75
  sync_service = SyncService(
73
- config=config,
76
+ app_config=app_config,
74
77
  entity_service=entity_service,
75
78
  entity_parser=entity_parser,
76
79
  entity_repository=entity_repository,
@@ -153,8 +156,16 @@ def display_detailed_sync_results(knowledge: SyncReport):
153
156
  console.print(knowledge_tree)
154
157
 
155
158
 
156
- async def run_sync(verbose: bool = False, watch: bool = False, console_status: bool = False):
159
+ async def run_sync(verbose: bool = False):
157
160
  """Run sync operation."""
161
+ _, session_maker = await db.get_or_create_db(
162
+ db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
163
+ )
164
+ project_repository = ProjectRepository(session_maker)
165
+ project = await project_repository.get_by_name(config.project)
166
+ if not project: # pragma: no cover
167
+ raise Exception(f"Project '{config.project}' not found")
168
+
158
169
  import time
159
170
 
160
171
  start_time = time.time()
@@ -162,50 +173,33 @@ async def run_sync(verbose: bool = False, watch: bool = False, console_status: b
162
173
  logger.info(
163
174
  "Sync command started",
164
175
  project=config.project,
165
- watch_mode=watch,
166
176
  verbose=verbose,
167
177
  directory=str(config.home),
168
178
  )
169
179
 
170
- sync_service = await get_sync_service()
180
+ sync_service = await get_sync_service(project)
171
181
 
172
- # Start watching if requested
173
- if watch:
174
- logger.info("Starting watch service after initial sync")
175
- watch_service = WatchService(
176
- sync_service=sync_service,
177
- file_service=sync_service.entity_service.file_service,
178
- config=config,
179
- )
182
+ logger.info("Running one-time sync")
183
+ knowledge_changes = await sync_service.sync(config.home, project_name=project.name)
180
184
 
181
- # full sync - no progress bars in watch mode
182
- await sync_service.sync(config.home)
185
+ # Log results
186
+ duration_ms = int((time.time() - start_time) * 1000)
187
+ logger.info(
188
+ "Sync command completed",
189
+ project=config.project,
190
+ total_changes=knowledge_changes.total,
191
+ new_files=len(knowledge_changes.new),
192
+ modified_files=len(knowledge_changes.modified),
193
+ deleted_files=len(knowledge_changes.deleted),
194
+ moved_files=len(knowledge_changes.moves),
195
+ duration_ms=duration_ms,
196
+ )
183
197
 
184
- # watch changes
185
- await watch_service.run() # pragma: no cover
198
+ # Display results
199
+ if verbose:
200
+ display_detailed_sync_results(knowledge_changes)
186
201
  else:
187
- # one time sync
188
- logger.info("Running one-time sync")
189
- knowledge_changes = await sync_service.sync(config.home)
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
202
+ display_sync_summary(knowledge_changes) # pragma: no cover
209
203
 
210
204
 
211
205
  @app.command()
@@ -216,22 +210,15 @@ def sync(
216
210
  "-v",
217
211
  help="Show detailed sync information.",
218
212
  ),
219
- watch: bool = typer.Option(
220
- False,
221
- "--watch",
222
- "-w",
223
- help="Start watching for changes after sync.",
224
- ),
225
213
  ) -> None:
226
214
  """Sync knowledge files with the database."""
227
215
  try:
228
216
  # Show which project we're syncing
229
- if not watch: # Don't show in watch mode as it would break the UI
230
- typer.echo(f"Syncing project: {config.project}")
231
- typer.echo(f"Project path: {config.home}")
217
+ typer.echo(f"Syncing project: {config.project}")
218
+ typer.echo(f"Project path: {config.home}")
232
219
 
233
220
  # Run sync
234
- asyncio.run(run_sync(verbose=verbose, watch=watch))
221
+ asyncio.run(run_sync(verbose=verbose))
235
222
 
236
223
  except Exception as e: # pragma: no cover
237
224
  if not isinstance(e, typer.Exit):
@@ -240,7 +227,6 @@ def sync(
240
227
  f"project={config.project},"
241
228
  f"error={str(e)},"
242
229
  f"error_type={type(e).__name__},"
243
- f"watch_mode={watch},"
244
230
  f"directory={str(config.home)}",
245
231
  )
246
232
  typer.echo(f"Error during sync: {e}", err=True)
@@ -90,7 +90,7 @@ def write_note(
90
90
  typer.echo("Empty content provided. Please provide non-empty content.", err=True)
91
91
  raise typer.Exit(1)
92
92
 
93
- note = asyncio.run(mcp_write_note(title, content, folder, tags))
93
+ note = asyncio.run(mcp_write_note.fn(title, content, folder, tags))
94
94
  rprint(note)
95
95
  except Exception as e: # pragma: no cover
96
96
  if not isinstance(e, typer.Exit):
@@ -103,7 +103,7 @@ def write_note(
103
103
  def read_note(identifier: str, page: int = 1, page_size: int = 10):
104
104
  """Read a markdown note from the knowledge base."""
105
105
  try:
106
- note = asyncio.run(mcp_read_note(identifier, page, page_size))
106
+ note = asyncio.run(mcp_read_note.fn(identifier, page, page_size))
107
107
  rprint(note)
108
108
  except Exception as e: # pragma: no cover
109
109
  if not isinstance(e, typer.Exit):
@@ -124,7 +124,7 @@ def build_context(
124
124
  """Get context needed to continue a discussion."""
125
125
  try:
126
126
  context = asyncio.run(
127
- mcp_build_context(
127
+ mcp_build_context.fn(
128
128
  url=url,
129
129
  depth=depth,
130
130
  timeframe=timeframe,
@@ -157,7 +157,7 @@ def recent_activity(
157
157
  """Get recent activity across the knowledge base."""
158
158
  try:
159
159
  context = asyncio.run(
160
- mcp_recent_activity(
160
+ mcp_recent_activity.fn(
161
161
  type=type, # pyright: ignore [reportArgumentType]
162
162
  depth=depth,
163
163
  timeframe=timeframe,
@@ -210,7 +210,7 @@ def search_notes(
210
210
  search_type = "text" if search_type is None else search_type
211
211
 
212
212
  results = asyncio.run(
213
- mcp_search(
213
+ mcp_search.fn(
214
214
  query,
215
215
  search_type=search_type,
216
216
  page=page,
@@ -241,7 +241,7 @@ def continue_conversation(
241
241
  """Prompt to continue a previous conversation or work session."""
242
242
  try:
243
243
  # Prompt functions return formatted strings directly
244
- session = asyncio.run(mcp_continue_conversation(topic=topic, timeframe=timeframe))
244
+ session = asyncio.run(mcp_continue_conversation.fn(topic=topic, timeframe=timeframe)) # type: ignore
245
245
  rprint(session)
246
246
  except Exception as e: # pragma: no cover
247
247
  if not isinstance(e, typer.Exit):
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
+ auth,
7
8
  db,
8
9
  import_chatgpt,
9
10
  import_claude_conversations,
@@ -15,12 +16,7 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
15
16
  sync,
16
17
  tool,
17
18
  )
18
- from basic_memory.config import config
19
- from basic_memory.services.initialization import ensure_initialization
20
19
 
21
20
  if __name__ == "__main__": # pragma: no cover
22
- # Run initialization if we are starting as a module
23
- ensure_initialization(config)
24
-
25
21
  # start the app
26
22
  app()
basic_memory/config.py CHANGED
@@ -2,76 +2,48 @@
2
2
 
3
3
  import json
4
4
  import os
5
+ from dataclasses import dataclass
5
6
  from pathlib import Path
6
- from typing import Any, Dict, Literal, Optional
7
+ from typing import Any, Dict, Literal, Optional, List
7
8
 
8
9
  from loguru import logger
9
10
  from pydantic import Field, field_validator
10
11
  from pydantic_settings import BaseSettings, SettingsConfigDict
11
12
 
12
13
  import basic_memory
13
- from basic_memory.utils import setup_logging
14
+ from basic_memory.utils import setup_logging, generate_permalink
15
+
14
16
 
15
17
  DATABASE_NAME = "memory.db"
18
+ APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
16
19
  DATA_DIR_NAME = ".basic-memory"
17
20
  CONFIG_FILE_NAME = "config.json"
21
+ WATCH_STATUS_JSON = "watch-status.json"
18
22
 
19
23
  Environment = Literal["test", "dev", "user"]
20
24
 
21
25
 
22
- class ProjectConfig(BaseSettings):
26
+ @dataclass
27
+ class ProjectConfig:
23
28
  """Configuration for a specific basic-memory project."""
24
29
 
25
- env: Environment = Field(default="dev", description="Environment name")
26
-
27
- # Default to ~/basic-memory but allow override with env var: BASIC_MEMORY_HOME
28
- home: Path = Field(
29
- default_factory=lambda: Path.home() / "basic-memory",
30
- description="Base path for basic-memory files",
31
- )
32
-
33
- # Name of the project
34
- project: str = Field(default="default", description="Project name")
35
-
36
- # Watch service configuration
37
- sync_delay: int = Field(
38
- default=1000, description="Milliseconds to wait after changes before syncing", gt=0
39
- )
40
-
41
- # update permalinks on move
42
- update_permalinks_on_move: bool = Field(
43
- default=False,
44
- description="Whether to update permalinks when files are moved or renamed. default (False)",
45
- )
46
-
47
- model_config = SettingsConfigDict(
48
- env_prefix="BASIC_MEMORY_",
49
- extra="ignore",
50
- env_file=".env",
51
- env_file_encoding="utf-8",
52
- )
30
+ name: str
31
+ home: Path
53
32
 
54
33
  @property
55
- def database_path(self) -> Path:
56
- """Get SQLite database path."""
57
- database_path = self.home / DATA_DIR_NAME / DATABASE_NAME
58
- if not database_path.exists():
59
- database_path.parent.mkdir(parents=True, exist_ok=True)
60
- database_path.touch()
61
- return database_path
34
+ def project(self):
35
+ return self.name
62
36
 
63
- @field_validator("home")
64
- @classmethod
65
- def ensure_path_exists(cls, v: Path) -> Path: # pragma: no cover
66
- """Ensure project path exists."""
67
- if not v.exists():
68
- v.mkdir(parents=True)
69
- return v
37
+ @property
38
+ def project_url(self) -> str: # pragma: no cover
39
+ return f"/{generate_permalink(self.name)}"
70
40
 
71
41
 
72
42
  class BasicMemoryConfig(BaseSettings):
73
43
  """Pydantic model for Basic Memory global configuration."""
74
44
 
45
+ env: Environment = Field(default="dev", description="Environment name")
46
+
75
47
  projects: Dict[str, str] = Field(
76
48
  default_factory=lambda: {"main": str(Path.home() / "basic-memory")},
77
49
  description="Mapping of project names to their filesystem paths",
@@ -81,8 +53,15 @@ class BasicMemoryConfig(BaseSettings):
81
53
  description="Name of the default project to use",
82
54
  )
83
55
 
56
+ # overridden by ~/.basic-memory/config.json
84
57
  log_level: str = "INFO"
85
58
 
59
+ # Watch service configuration
60
+ sync_delay: int = Field(
61
+ default=1000, description="Milliseconds to wait after changes before syncing", gt=0
62
+ )
63
+
64
+ # update permalinks on move
86
65
  update_permalinks_on_move: bool = Field(
87
66
  default=False,
88
67
  description="Whether to update permalinks when files are moved or renamed. default (False)",
@@ -96,25 +75,84 @@ class BasicMemoryConfig(BaseSettings):
96
75
  model_config = SettingsConfigDict(
97
76
  env_prefix="BASIC_MEMORY_",
98
77
  extra="ignore",
78
+ env_file=".env",
79
+ env_file_encoding="utf-8",
99
80
  )
100
81
 
82
+ def get_project_path(self, project_name: Optional[str] = None) -> Path: # pragma: no cover
83
+ """Get the path for a specific project or the default project."""
84
+ name = project_name or self.default_project
85
+
86
+ if name not in self.projects:
87
+ raise ValueError(f"Project '{name}' not found in configuration")
88
+
89
+ return Path(self.projects[name])
90
+
101
91
  def model_post_init(self, __context: Any) -> None:
102
92
  """Ensure configuration is valid after initialization."""
103
93
  # Ensure main project exists
104
- if "main" not in self.projects:
94
+ if "main" not in self.projects: # pragma: no cover
105
95
  self.projects["main"] = str(Path.home() / "basic-memory")
106
96
 
107
97
  # Ensure default project is valid
108
- if self.default_project not in self.projects:
98
+ if self.default_project not in self.projects: # pragma: no cover
109
99
  self.default_project = "main"
110
100
 
101
+ @property
102
+ def app_database_path(self) -> Path:
103
+ """Get the path to the app-level database.
104
+
105
+ This is the single database that will store all knowledge data
106
+ across all projects.
107
+ """
108
+ database_path = Path.home() / DATA_DIR_NAME / APP_DATABASE_NAME
109
+ if not database_path.exists(): # pragma: no cover
110
+ database_path.parent.mkdir(parents=True, exist_ok=True)
111
+ database_path.touch()
112
+ return database_path
113
+
114
+ @property
115
+ def database_path(self) -> Path:
116
+ """Get SQLite database path.
117
+
118
+ Rreturns the app-level database path
119
+ for backward compatibility in the codebase.
120
+ """
121
+
122
+ # Load the app-level database path from the global config
123
+ config = config_manager.load_config() # pragma: no cover
124
+ return config.app_database_path # pragma: no cover
125
+
126
+ @property
127
+ def project_list(self) -> List[ProjectConfig]: # pragma: no cover
128
+ """Get all configured projects as ProjectConfig objects."""
129
+ return [ProjectConfig(name=name, home=Path(path)) for name, path in self.projects.items()]
130
+
131
+ @field_validator("projects")
132
+ @classmethod
133
+ def ensure_project_paths_exists(cls, v: Dict[str, str]) -> Dict[str, str]: # pragma: no cover
134
+ """Ensure project path exists."""
135
+ for name, path_value in v.items():
136
+ path = Path(path_value)
137
+ if not Path(path).exists():
138
+ try:
139
+ path.mkdir(parents=True)
140
+ except Exception as e:
141
+ logger.error(f"Failed to create project path: {e}")
142
+ raise e
143
+ return v
144
+
111
145
 
112
146
  class ConfigManager:
113
147
  """Manages Basic Memory configuration."""
114
148
 
115
149
  def __init__(self) -> None:
116
150
  """Initialize the configuration manager."""
117
- self.config_dir = Path.home() / DATA_DIR_NAME
151
+ home = os.getenv("HOME", Path.home())
152
+ if isinstance(home, str):
153
+ home = Path(home)
154
+
155
+ self.config_dir = home / DATA_DIR_NAME
118
156
  self.config_file = self.config_dir / CONFIG_FILE_NAME
119
157
 
120
158
  # Ensure config directory exists
@@ -129,7 +167,7 @@ class ConfigManager:
129
167
  try:
130
168
  data = json.loads(self.config_file.read_text(encoding="utf-8"))
131
169
  return BasicMemoryConfig(**data)
132
- except Exception as e:
170
+ except Exception as e: # pragma: no cover
133
171
  logger.error(f"Failed to load config: {e}")
134
172
  config = BasicMemoryConfig()
135
173
  self.save_config(config)
@@ -156,37 +194,25 @@ class ConfigManager:
156
194
  """Get the default project name."""
157
195
  return self.config.default_project
158
196
 
159
- def get_project_path(self, project_name: Optional[str] = None) -> Path:
160
- """Get the path for a specific project or the default project."""
161
- name = project_name or self.config.default_project
162
-
163
- # Check if specified in environment variable
164
- if not project_name and "BASIC_MEMORY_PROJECT" in os.environ:
165
- name = os.environ["BASIC_MEMORY_PROJECT"]
166
-
167
- if name not in self.config.projects:
168
- raise ValueError(f"Project '{name}' not found in configuration")
169
-
170
- return Path(self.config.projects[name])
171
-
172
- def add_project(self, name: str, path: str) -> None:
197
+ def add_project(self, name: str, path: str) -> ProjectConfig:
173
198
  """Add a new project to the configuration."""
174
- if name in self.config.projects:
199
+ if name in self.config.projects: # pragma: no cover
175
200
  raise ValueError(f"Project '{name}' already exists")
176
201
 
177
202
  # Ensure the path exists
178
203
  project_path = Path(path)
179
- project_path.mkdir(parents=True, exist_ok=True)
204
+ project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
180
205
 
181
206
  self.config.projects[name] = str(project_path)
182
207
  self.save_config(self.config)
208
+ return ProjectConfig(name=name, home=project_path)
183
209
 
184
210
  def remove_project(self, name: str) -> None:
185
211
  """Remove a project from the configuration."""
186
- if name not in self.config.projects:
212
+ if name not in self.config.projects: # pragma: no cover
187
213
  raise ValueError(f"Project '{name}' not found")
188
214
 
189
- if name == self.config.default_project:
215
+ if name == self.config.default_project: # pragma: no cover
190
216
  raise ValueError(f"Cannot remove the default project '{name}'")
191
217
 
192
218
  del self.config.projects[name]
@@ -202,33 +228,59 @@ class ConfigManager:
202
228
 
203
229
 
204
230
  def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
205
- """Get a project configuration for the specified project."""
206
- config_manager = ConfigManager()
231
+ """
232
+ Get the project configuration for the current session.
233
+ If project_name is provided, it will be used instead of the default project.
234
+ """
207
235
 
208
- # Get project name from environment variable or use provided name or default
209
- actual_project_name = os.environ.get(
210
- "BASIC_MEMORY_PROJECT", project_name or config_manager.default_project
211
- )
236
+ actual_project_name = None
212
237
 
213
- update_permalinks_on_move = config_manager.load_config().update_permalinks_on_move
214
- try:
215
- project_path = config_manager.get_project_path(actual_project_name)
216
- return ProjectConfig(
217
- home=project_path,
218
- project=actual_project_name,
219
- update_permalinks_on_move=update_permalinks_on_move,
238
+ # load the config from file
239
+ global app_config
240
+ app_config = config_manager.load_config()
241
+
242
+ # Get project name from environment variable
243
+ os_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
244
+ if os_project_name: # pragma: no cover
245
+ logger.warning(
246
+ 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}"
220
247
  )
221
- except ValueError: # pragma: no cover
222
- logger.warning(f"Project '{actual_project_name}' not found, using default")
223
- project_path = config_manager.get_project_path(config_manager.default_project)
224
- return ProjectConfig(home=project_path, project=config_manager.default_project)
248
+ actual_project_name = project_name
249
+ # if the project_name is passed in, use it
250
+ elif not project_name:
251
+ # use default
252
+ actual_project_name = app_config.default_project
253
+ else: # pragma: no cover
254
+ actual_project_name = project_name
255
+
256
+ # the config contains a dict[str,str] of project names and absolute paths
257
+ assert actual_project_name is not None, "actual_project_name cannot be None"
258
+
259
+ project_path = app_config.projects.get(actual_project_name)
260
+ if not project_path: # pragma: no cover
261
+ raise ValueError(f"Project '{actual_project_name}' not found")
262
+
263
+ return ProjectConfig(name=actual_project_name, home=Path(project_path))
225
264
 
226
265
 
227
266
  # Create config manager
228
267
  config_manager = ConfigManager()
229
268
 
230
- # Load project config for current context
231
- config = get_project_config()
269
+ # Export the app-level config
270
+ app_config: BasicMemoryConfig = config_manager.config
271
+
272
+ # Load project config for the default project (backward compatibility)
273
+ config: ProjectConfig = get_project_config()
274
+
275
+
276
+ def update_current_project(project_name: str) -> None:
277
+ """Update the global config to use a different project.
278
+
279
+ This is used by the CLI when --project flag is specified.
280
+ """
281
+ global config
282
+ config = get_project_config(project_name) # pragma: no cover
283
+
232
284
 
233
285
  # setup logging to a single log file in user home directory
234
286
  user_home = Path.home()
@@ -236,6 +288,7 @@ log_dir = user_home / DATA_DIR_NAME
236
288
  log_dir.mkdir(parents=True, exist_ok=True)
237
289
 
238
290
 
291
+ # Process info for logging
239
292
  def get_process_name(): # pragma: no cover
240
293
  """
241
294
  get the type of process for logging
@@ -258,6 +311,9 @@ process_name = get_process_name()
258
311
  _LOGGING_SETUP = False
259
312
 
260
313
 
314
+ # Logging
315
+
316
+
261
317
  def setup_basic_memory_logging(): # pragma: no cover
262
318
  """Set up logging for basic-memory, ensuring it only happens once."""
263
319
  global _LOGGING_SETUP
@@ -267,7 +323,7 @@ def setup_basic_memory_logging(): # pragma: no cover
267
323
  return
268
324
 
269
325
  setup_logging(
270
- env=config.env,
326
+ env=config_manager.config.env,
271
327
  home_dir=user_home, # Use user home for logs
272
328
  log_level=config_manager.load_config().log_level,
273
329
  log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
basic_memory/db.py CHANGED
@@ -4,8 +4,7 @@ from enum import Enum, auto
4
4
  from pathlib import Path
5
5
  from typing import AsyncGenerator, Optional
6
6
 
7
-
8
- from basic_memory.config import ProjectConfig
7
+ from basic_memory.config import BasicMemoryConfig
9
8
  from alembic import command
10
9
  from alembic.config import Config
11
10
 
@@ -147,7 +146,7 @@ async def engine_session_factory(
147
146
 
148
147
 
149
148
  async def run_migrations(
150
- app_config: ProjectConfig, database_type=DatabaseType.FILESYSTEM
149
+ app_config: BasicMemoryConfig, database_type=DatabaseType.FILESYSTEM
151
150
  ): # pragma: no cover
152
151
  """Run any pending alembic migrations."""
153
152
  logger.info("Running database migrations...")
@@ -172,7 +171,10 @@ async def run_migrations(
172
171
  logger.info("Migrations completed successfully")
173
172
 
174
173
  _, session_maker = await get_or_create_db(app_config.database_path, database_type)
175
- await SearchRepository(session_maker).init_search_index()
174
+
175
+ # initialize the search Index schema
176
+ # the project_id is not used for init_search_index, so we pass a dummy value
177
+ await SearchRepository(session_maker, 1).init_search_index()
176
178
  except Exception as e: # pragma: no cover
177
179
  logger.error(f"Error running migrations: {e}")
178
180
  raise