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
basic_memory/config.py ADDED
@@ -0,0 +1,616 @@
1
+ """Configuration management for basic-memory."""
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Literal, Optional, List, Tuple
9
+ from enum import Enum
10
+
11
+ from loguru import logger
12
+ from pydantic import BaseModel, Field, model_validator
13
+ from pydantic_settings import BaseSettings, SettingsConfigDict
14
+
15
+ from basic_memory.utils import setup_logging, generate_permalink
16
+
17
+
18
+ DATABASE_NAME = "memory.db"
19
+ APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
20
+ DATA_DIR_NAME = ".basic-memory"
21
+ CONFIG_FILE_NAME = "config.json"
22
+ WATCH_STATUS_JSON = "watch-status.json"
23
+
24
+ Environment = Literal["test", "dev", "user"]
25
+
26
+
27
+ class DatabaseBackend(str, Enum):
28
+ """Supported database backends."""
29
+
30
+ SQLITE = "sqlite"
31
+ POSTGRES = "postgres"
32
+
33
+
34
+ @dataclass
35
+ class ProjectConfig:
36
+ """Configuration for a specific basic-memory project."""
37
+
38
+ name: str
39
+ home: Path
40
+
41
+ @property
42
+ def project(self):
43
+ return self.name
44
+
45
+ @property
46
+ def project_url(self) -> str: # pragma: no cover
47
+ return f"/{generate_permalink(self.name)}"
48
+
49
+
50
+ class CloudProjectConfig(BaseModel):
51
+ """Sync configuration for a cloud project.
52
+
53
+ This tracks the local working directory and sync state for a project
54
+ that is synced with Basic Memory Cloud.
55
+ """
56
+
57
+ local_path: str = Field(description="Local working directory path for this cloud project")
58
+ last_sync: Optional[datetime] = Field(
59
+ default=None, description="Timestamp of last successful sync operation"
60
+ )
61
+ bisync_initialized: bool = Field(
62
+ default=False, description="Whether rclone bisync baseline has been established"
63
+ )
64
+
65
+
66
+ class BasicMemoryConfig(BaseSettings):
67
+ """Pydantic model for Basic Memory global configuration."""
68
+
69
+ env: Environment = Field(default="dev", description="Environment name")
70
+
71
+ projects: Dict[str, str] = Field(
72
+ default_factory=lambda: {
73
+ "main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
74
+ }
75
+ if os.getenv("BASIC_MEMORY_HOME")
76
+ else {},
77
+ description="Mapping of project names to their filesystem paths",
78
+ )
79
+ default_project: str = Field(
80
+ default="main",
81
+ description="Name of the default project to use",
82
+ )
83
+ default_project_mode: bool = Field(
84
+ default=False,
85
+ description="When True, MCP tools automatically use default_project when no project parameter is specified. Enables simplified UX for single-project workflows.",
86
+ )
87
+
88
+ # overridden by ~/.basic-memory/config.json
89
+ log_level: str = "INFO"
90
+
91
+ # Database configuration
92
+ database_backend: DatabaseBackend = Field(
93
+ default=DatabaseBackend.SQLITE,
94
+ description="Database backend to use (sqlite or postgres)",
95
+ )
96
+
97
+ database_url: Optional[str] = Field(
98
+ default=None,
99
+ description="Database connection URL. For Postgres, use postgresql+asyncpg://user:pass@host:port/db. If not set, SQLite will use default path.",
100
+ )
101
+
102
+ # Database connection pool configuration (Postgres only)
103
+ db_pool_size: int = Field(
104
+ default=20,
105
+ description="Number of connections to keep in the pool (Postgres only)",
106
+ gt=0,
107
+ )
108
+ db_pool_overflow: int = Field(
109
+ default=40,
110
+ description="Max additional connections beyond pool_size under load (Postgres only)",
111
+ gt=0,
112
+ )
113
+ db_pool_recycle: int = Field(
114
+ default=180,
115
+ description="Recycle connections after N seconds to prevent stale connections. Default 180s works well with Neon's ~5 minute scale-to-zero (Postgres only)",
116
+ gt=0,
117
+ )
118
+
119
+ # Watch service configuration
120
+ sync_delay: int = Field(
121
+ default=1000, description="Milliseconds to wait after changes before syncing", gt=0
122
+ )
123
+
124
+ watch_project_reload_interval: int = Field(
125
+ default=300,
126
+ description="Seconds between reloading project list in watch service. Higher values reduce CPU usage by minimizing watcher restarts. Default 300s (5 min) balances efficiency with responsiveness to new projects.",
127
+ gt=0,
128
+ )
129
+
130
+ # update permalinks on move
131
+ update_permalinks_on_move: bool = Field(
132
+ default=False,
133
+ description="Whether to update permalinks when files are moved or renamed. default (False)",
134
+ )
135
+
136
+ sync_changes: bool = Field(
137
+ default=True,
138
+ description="Whether to sync changes in real time. default (True)",
139
+ )
140
+
141
+ sync_thread_pool_size: int = Field(
142
+ default=4,
143
+ description="Size of thread pool for file I/O operations in sync service. Default of 4 is optimized for cloud deployments with 1-2GB RAM.",
144
+ gt=0,
145
+ )
146
+
147
+ sync_max_concurrent_files: int = Field(
148
+ default=10,
149
+ description="Maximum number of files to process concurrently during sync. Limits memory usage on large projects (2000+ files). Lower values reduce memory consumption.",
150
+ gt=0,
151
+ )
152
+
153
+ kebab_filenames: bool = Field(
154
+ default=False,
155
+ description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks",
156
+ )
157
+
158
+ disable_permalinks: bool = Field(
159
+ default=False,
160
+ 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.",
161
+ )
162
+
163
+ skip_initialization_sync: bool = Field(
164
+ default=False,
165
+ description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
166
+ )
167
+
168
+ # File formatting configuration
169
+ format_on_save: bool = Field(
170
+ default=False,
171
+ description="Automatically format files after saving using configured formatter. Disabled by default.",
172
+ )
173
+
174
+ formatter_command: Optional[str] = Field(
175
+ default=None,
176
+ description="External formatter command. Use {file} as placeholder for file path. If not set, uses built-in mdformat (Python, no Node.js required). Set to 'npx prettier --write {file}' for Prettier.",
177
+ )
178
+
179
+ formatters: Dict[str, str] = Field(
180
+ default_factory=dict,
181
+ description="Per-extension formatters. Keys are extensions (without dot), values are commands. Example: {'md': 'prettier --write {file}', 'json': 'prettier --write {file}'}",
182
+ )
183
+
184
+ formatter_timeout: float = Field(
185
+ default=5.0,
186
+ description="Maximum seconds to wait for formatter to complete",
187
+ gt=0,
188
+ )
189
+
190
+ # Project path constraints
191
+ project_root: Optional[str] = Field(
192
+ default=None,
193
+ description="If set, all projects must be created underneath this directory. Paths will be sanitized and constrained to this root. If not set, projects can be created anywhere (default behavior).",
194
+ )
195
+
196
+ # Cloud configuration
197
+ cloud_client_id: str = Field(
198
+ default="client_01K6KWQPW6J1M8VV7R3TZP5A6M",
199
+ description="OAuth client ID for Basic Memory Cloud",
200
+ )
201
+
202
+ cloud_domain: str = Field(
203
+ default="https://eloquent-lotus-05.authkit.app",
204
+ description="AuthKit domain for Basic Memory Cloud",
205
+ )
206
+
207
+ cloud_host: str = Field(
208
+ default_factory=lambda: os.getenv(
209
+ "BASIC_MEMORY_CLOUD_HOST", "https://cloud.basicmemory.com"
210
+ ),
211
+ description="Basic Memory Cloud host URL",
212
+ )
213
+
214
+ cloud_mode: bool = Field(
215
+ default=False,
216
+ description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
217
+ )
218
+
219
+ cloud_projects: Dict[str, CloudProjectConfig] = Field(
220
+ default_factory=dict,
221
+ description="Cloud project sync configuration mapping project names to their local paths and sync state",
222
+ )
223
+
224
+ # Telemetry configuration (Homebrew-style opt-out)
225
+ telemetry_enabled: bool = Field(
226
+ default=True,
227
+ description="Send anonymous usage statistics to help improve Basic Memory. Disable with: bm telemetry disable",
228
+ )
229
+
230
+ telemetry_notice_shown: bool = Field(
231
+ default=False,
232
+ description="Whether the one-time telemetry notice has been shown to the user",
233
+ )
234
+
235
+ @property
236
+ def is_test_env(self) -> bool:
237
+ """Check if running in a test environment.
238
+
239
+ Returns True if any of:
240
+ - env field is set to "test"
241
+ - BASIC_MEMORY_ENV environment variable is "test"
242
+ - PYTEST_CURRENT_TEST environment variable is set (pytest is running)
243
+
244
+ Used to disable features like telemetry and file watchers during tests.
245
+ """
246
+ return (
247
+ self.env == "test"
248
+ or os.getenv("BASIC_MEMORY_ENV", "").lower() == "test"
249
+ or os.getenv("PYTEST_CURRENT_TEST") is not None
250
+ )
251
+
252
+ @property
253
+ def cloud_mode_enabled(self) -> bool:
254
+ """Check if cloud mode is enabled.
255
+
256
+ Priority:
257
+ 1. BASIC_MEMORY_CLOUD_MODE environment variable
258
+ 2. Config file value (cloud_mode)
259
+ """
260
+ env_value = os.environ.get("BASIC_MEMORY_CLOUD_MODE", "").lower()
261
+ if env_value in ("true", "1", "yes"):
262
+ return True
263
+ elif env_value in ("false", "0", "no"):
264
+ return False
265
+ # Fall back to config file value
266
+ return self.cloud_mode
267
+
268
+ @classmethod
269
+ def for_cloud_tenant(
270
+ cls,
271
+ database_url: str,
272
+ projects: Optional[Dict[str, str]] = None,
273
+ ) -> "BasicMemoryConfig":
274
+ """Create config for cloud tenant - no config.json, database is source of truth.
275
+
276
+ This factory method creates a BasicMemoryConfig suitable for cloud deployments
277
+ where:
278
+ - Database is Postgres (Neon), not SQLite
279
+ - Projects are discovered from the database, not config file
280
+ - Path validation is skipped (no local filesystem in cloud)
281
+ - Initialization sync is skipped (stateless deployment)
282
+
283
+ Args:
284
+ database_url: Postgres connection URL for tenant database
285
+ projects: Optional project mapping (usually empty, discovered from DB)
286
+
287
+ Returns:
288
+ BasicMemoryConfig configured for cloud mode
289
+ """
290
+ return cls(
291
+ database_backend=DatabaseBackend.POSTGRES,
292
+ database_url=database_url,
293
+ projects=projects or {},
294
+ cloud_mode=True,
295
+ skip_initialization_sync=True,
296
+ )
297
+
298
+ model_config = SettingsConfigDict(
299
+ env_prefix="BASIC_MEMORY_",
300
+ extra="ignore",
301
+ )
302
+
303
+ def get_project_path(self, project_name: Optional[str] = None) -> Path: # pragma: no cover
304
+ """Get the path for a specific project or the default project."""
305
+ name = project_name or self.default_project
306
+
307
+ if name not in self.projects:
308
+ raise ValueError(f"Project '{name}' not found in configuration")
309
+
310
+ return Path(self.projects[name])
311
+
312
+ def model_post_init(self, __context: Any) -> None:
313
+ """Ensure configuration is valid after initialization."""
314
+ # Skip project initialization in cloud mode - projects are discovered from DB
315
+ if self.database_backend == DatabaseBackend.POSTGRES:
316
+ return
317
+
318
+ # Ensure at least one project exists; if none exist then create main
319
+ if not self.projects: # pragma: no cover
320
+ self.projects["main"] = str(
321
+ Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
322
+ )
323
+
324
+ # Ensure default project is valid (i.e. points to an existing project)
325
+ if self.default_project not in self.projects: # pragma: no cover
326
+ # Set default to first available project
327
+ self.default_project = next(iter(self.projects.keys()))
328
+
329
+ @property
330
+ def app_database_path(self) -> Path:
331
+ """Get the path to the app-level database.
332
+
333
+ This is the single database that will store all knowledge data
334
+ across all projects.
335
+ """
336
+ database_path = Path.home() / DATA_DIR_NAME / APP_DATABASE_NAME
337
+ if not database_path.exists(): # pragma: no cover
338
+ database_path.parent.mkdir(parents=True, exist_ok=True)
339
+ database_path.touch()
340
+ return database_path
341
+
342
+ @property
343
+ def database_path(self) -> Path:
344
+ """Get SQLite database path.
345
+
346
+ Rreturns the app-level database path
347
+ for backward compatibility in the codebase.
348
+ """
349
+
350
+ # Load the app-level database path from the global config
351
+ config_manager = ConfigManager()
352
+ config = config_manager.load_config() # pragma: no cover
353
+ return config.app_database_path # pragma: no cover
354
+
355
+ @property
356
+ def project_list(self) -> List[ProjectConfig]: # pragma: no cover
357
+ """Get all configured projects as ProjectConfig objects."""
358
+ return [ProjectConfig(name=name, home=Path(path)) for name, path in self.projects.items()]
359
+
360
+ @model_validator(mode="after")
361
+ def ensure_project_paths_exists(self) -> "BasicMemoryConfig": # pragma: no cover
362
+ """Ensure project paths exist.
363
+
364
+ Skips path creation when using Postgres backend (cloud mode) since
365
+ cloud tenants don't use local filesystem paths.
366
+ """
367
+ # Skip path creation for cloud mode - no local filesystem
368
+ if self.database_backend == DatabaseBackend.POSTGRES:
369
+ return self
370
+
371
+ for name, path_value in self.projects.items():
372
+ path = Path(path_value)
373
+ if not path.exists():
374
+ try:
375
+ path.mkdir(parents=True)
376
+ except Exception as e:
377
+ logger.error(f"Failed to create project path: {e}")
378
+ raise e
379
+ return self
380
+
381
+ @property
382
+ def data_dir_path(self):
383
+ return Path.home() / DATA_DIR_NAME
384
+
385
+
386
+ # Module-level cache for configuration
387
+ _CONFIG_CACHE: Optional[BasicMemoryConfig] = None
388
+
389
+
390
+ class ConfigManager:
391
+ """Manages Basic Memory configuration."""
392
+
393
+ def __init__(self) -> None:
394
+ """Initialize the configuration manager."""
395
+ home = os.getenv("HOME", Path.home())
396
+ if isinstance(home, str):
397
+ home = Path(home)
398
+
399
+ # Allow override via environment variable
400
+ if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
401
+ self.config_dir = Path(config_dir)
402
+ else:
403
+ self.config_dir = home / DATA_DIR_NAME
404
+
405
+ self.config_file = self.config_dir / CONFIG_FILE_NAME
406
+
407
+ # Ensure config directory exists
408
+ self.config_dir.mkdir(parents=True, exist_ok=True)
409
+
410
+ @property
411
+ def config(self) -> BasicMemoryConfig:
412
+ """Get configuration, loading it lazily if needed."""
413
+ return self.load_config()
414
+
415
+ def load_config(self) -> BasicMemoryConfig:
416
+ """Load configuration from file or create default.
417
+
418
+ Environment variables take precedence over file config values,
419
+ following Pydantic Settings best practices.
420
+
421
+ Uses module-level cache for performance across ConfigManager instances.
422
+ """
423
+ global _CONFIG_CACHE
424
+
425
+ # Return cached config if available
426
+ if _CONFIG_CACHE is not None:
427
+ return _CONFIG_CACHE
428
+
429
+ if self.config_file.exists():
430
+ try:
431
+ file_data = json.loads(self.config_file.read_text(encoding="utf-8"))
432
+
433
+ # First, create config from environment variables (Pydantic will read them)
434
+ # Then overlay with file data for fields that aren't set via env vars
435
+ # This ensures env vars take precedence
436
+
437
+ # Get env-based config fields that are actually set
438
+ env_config = BasicMemoryConfig()
439
+ env_dict = env_config.model_dump()
440
+
441
+ # Merge: file data as base, but only use it for fields not set by env
442
+ # We detect env-set fields by comparing to default values
443
+ merged_data = file_data.copy()
444
+
445
+ # For fields that have env var overrides, use those instead of file values
446
+ # The env_prefix is "BASIC_MEMORY_" so we check those
447
+ for field_name in BasicMemoryConfig.model_fields.keys():
448
+ env_var_name = f"BASIC_MEMORY_{field_name.upper()}"
449
+ if env_var_name in os.environ:
450
+ # Environment variable is set, use it
451
+ merged_data[field_name] = env_dict[field_name]
452
+
453
+ _CONFIG_CACHE = BasicMemoryConfig(**merged_data)
454
+ return _CONFIG_CACHE
455
+ except Exception as e: # pragma: no cover
456
+ logger.exception(f"Failed to load config: {e}")
457
+ raise e
458
+ else:
459
+ config = BasicMemoryConfig()
460
+ self.save_config(config)
461
+ return config
462
+
463
+ def save_config(self, config: BasicMemoryConfig) -> None:
464
+ """Save configuration to file and invalidate cache."""
465
+ global _CONFIG_CACHE
466
+ save_basic_memory_config(self.config_file, config)
467
+ # Invalidate cache so next load_config() reads fresh data
468
+ _CONFIG_CACHE = None
469
+
470
+ @property
471
+ def projects(self) -> Dict[str, str]:
472
+ """Get all configured projects."""
473
+ return self.config.projects.copy()
474
+
475
+ @property
476
+ def default_project(self) -> str:
477
+ """Get the default project name."""
478
+ return self.config.default_project
479
+
480
+ def add_project(self, name: str, path: str) -> ProjectConfig:
481
+ """Add a new project to the configuration."""
482
+ project_name, _ = self.get_project(name)
483
+ if project_name: # pragma: no cover
484
+ raise ValueError(f"Project '{name}' already exists")
485
+
486
+ # Ensure the path exists
487
+ project_path = Path(path)
488
+ project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
489
+
490
+ # Load config, modify it, and save it
491
+ config = self.load_config()
492
+ config.projects[name] = str(project_path)
493
+ self.save_config(config)
494
+ return ProjectConfig(name=name, home=project_path)
495
+
496
+ def remove_project(self, name: str) -> None:
497
+ """Remove a project from the configuration."""
498
+
499
+ project_name, path = self.get_project(name)
500
+ if not project_name: # pragma: no cover
501
+ raise ValueError(f"Project '{name}' not found")
502
+
503
+ # Load config, check, modify, and save
504
+ config = self.load_config()
505
+ if project_name == config.default_project: # pragma: no cover
506
+ raise ValueError(f"Cannot remove the default project '{name}'")
507
+
508
+ # Use the found project_name (which may differ from input name due to permalink matching)
509
+ del config.projects[project_name]
510
+ self.save_config(config)
511
+
512
+ def set_default_project(self, name: str) -> None:
513
+ """Set the default project."""
514
+ project_name, path = self.get_project(name)
515
+ if not project_name: # pragma: no cover
516
+ raise ValueError(f"Project '{name}' not found")
517
+
518
+ # Load config, modify, and save
519
+ config = self.load_config()
520
+ config.default_project = project_name
521
+ self.save_config(config)
522
+
523
+ def get_project(self, name: str) -> Tuple[str, str] | Tuple[None, None]:
524
+ """Look up a project from the configuration by name or permalink"""
525
+ project_permalink = generate_permalink(name)
526
+ app_config = self.config
527
+ for project_name, path in app_config.projects.items():
528
+ if project_permalink == generate_permalink(project_name):
529
+ return project_name, path
530
+ return None, None
531
+
532
+
533
+ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
534
+ """
535
+ Get the project configuration for the current session.
536
+ If project_name is provided, it will be used instead of the default project.
537
+ """
538
+
539
+ actual_project_name = None
540
+
541
+ # load the config from file
542
+ config_manager = ConfigManager()
543
+ app_config = config_manager.load_config()
544
+
545
+ # Get project name from environment variable
546
+ os_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
547
+ if os_project_name: # pragma: no cover
548
+ logger.warning(
549
+ f"BASIC_MEMORY_PROJECT is not supported anymore. Set the default project in the config instead. Setting default project to {os_project_name}"
550
+ )
551
+ actual_project_name = project_name
552
+ # if the project_name is passed in, use it
553
+ elif not project_name:
554
+ # use default
555
+ actual_project_name = app_config.default_project
556
+ else: # pragma: no cover
557
+ actual_project_name = project_name
558
+
559
+ # the config contains a dict[str,str] of project names and absolute paths
560
+ assert actual_project_name is not None, "actual_project_name cannot be None"
561
+
562
+ project_permalink = generate_permalink(actual_project_name)
563
+
564
+ for name, path in app_config.projects.items():
565
+ if project_permalink == generate_permalink(name):
566
+ return ProjectConfig(name=name, home=Path(path))
567
+
568
+ # otherwise raise error
569
+ raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover
570
+
571
+
572
+ def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
573
+ """Save configuration to file."""
574
+ try:
575
+ # Use model_dump with mode='json' to serialize datetime objects properly
576
+ config_dict = config.model_dump(mode="json")
577
+ file_path.write_text(json.dumps(config_dict, indent=2))
578
+ except Exception as e: # pragma: no cover
579
+ logger.error(f"Failed to save config: {e}")
580
+
581
+
582
+ # Logging initialization functions for different entry points
583
+
584
+
585
+ def init_cli_logging() -> None: # pragma: no cover
586
+ """Initialize logging for CLI commands - file only.
587
+
588
+ CLI commands should not log to stdout to avoid interfering with
589
+ command output and shell integration.
590
+ """
591
+ log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
592
+ setup_logging(log_level=log_level, log_to_file=True)
593
+
594
+
595
+ def init_mcp_logging() -> None: # pragma: no cover
596
+ """Initialize logging for MCP server - file only.
597
+
598
+ MCP server must not log to stdout as it would corrupt the
599
+ JSON-RPC protocol communication.
600
+ """
601
+ log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
602
+ setup_logging(log_level=log_level, log_to_file=True)
603
+
604
+
605
+ def init_api_logging() -> None: # pragma: no cover
606
+ """Initialize logging for API server.
607
+
608
+ Cloud mode (BASIC_MEMORY_CLOUD_MODE=1): stdout with structured context
609
+ Local mode: file only
610
+ """
611
+ log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
612
+ cloud_mode = os.getenv("BASIC_MEMORY_CLOUD_MODE", "").lower() in ("1", "true")
613
+ if cloud_mode:
614
+ setup_logging(log_level=log_level, log_to_stdout=True, structured_context=True)
615
+ else:
616
+ setup_logging(log_level=log_level, log_to_file=True)