basic-memory 0.2.12__py3-none-any.whl → 0.16.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +27 -3
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/api/app.py +63 -31
- basic_memory/api/routers/__init__.py +4 -1
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +165 -28
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +28 -67
- basic_memory/api/routers/project_router.py +406 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +219 -14
- basic_memory/api/routers/search_router.py +21 -13
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +52 -1
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +13 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +51 -0
- basic_memory/cli/commands/db.py +26 -7
- basic_memory/cli/commands/import_chatgpt.py +83 -0
- basic_memory/cli/commands/import_claude_conversations.py +86 -0
- basic_memory/cli/commands/import_claude_projects.py +85 -0
- basic_memory/cli/commands/import_memory_json.py +35 -92
- basic_memory/cli/commands/mcp.py +84 -10
- basic_memory/cli/commands/project.py +876 -0
- basic_memory/cli/commands/status.py +47 -30
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +13 -6
- basic_memory/config.py +481 -22
- basic_memory/db.py +192 -32
- basic_memory/deps.py +252 -22
- basic_memory/file_utils.py +113 -58
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +177 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +143 -23
- basic_memory/markdown/markdown_processor.py +3 -3
- basic_memory/markdown/plugins.py +39 -21
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +28 -13
- basic_memory/mcp/async_client.py +134 -4
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +7 -13
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +130 -0
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +225 -0
- basic_memory/mcp/tools/edit_note.py +320 -0
- basic_memory/mcp/tools/list_directory.py +167 -0
- basic_memory/mcp/tools/move_note.py +545 -0
- basic_memory/mcp/tools/project_management.py +200 -0
- basic_memory/mcp/tools/read_content.py +271 -0
- basic_memory/mcp/tools/read_note.py +255 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +369 -14
- basic_memory/mcp/tools/utils.py +374 -16
- basic_memory/mcp/tools/view_note.py +77 -0
- basic_memory/mcp/tools/write_note.py +207 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +67 -15
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +10 -6
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +229 -7
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +103 -0
- basic_memory/repository/relation_repository.py +21 -2
- basic_memory/repository/repository.py +147 -29
- basic_memory/repository/search_repository.py +437 -59
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +97 -8
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +188 -23
- basic_memory/schemas/project_info.py +211 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +57 -3
- basic_memory/schemas/response.py +9 -1
- basic_memory/schemas/search.py +33 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +251 -106
- basic_memory/services/directory_service.py +295 -0
- basic_memory/services/entity_service.py +595 -60
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +284 -30
- basic_memory/services/initialization.py +191 -0
- basic_memory/services/link_resolver.py +50 -56
- basic_memory/services/project_service.py +863 -0
- basic_memory/services/search_service.py +172 -34
- basic_memory/sync/__init__.py +3 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1176 -96
- basic_memory/sync/watch_service.py +412 -135
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +388 -28
- basic_memory-0.16.1.dist-info/METADATA +493 -0
- basic_memory-0.16.1.dist-info/RECORD +148 -0
- {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -203
- basic_memory/mcp/tools/knowledge.py +0 -56
- basic_memory/mcp/tools/memory.py +0 -151
- basic_memory/mcp/tools/notes.py +0 -122
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -34
- basic_memory-0.2.12.dist-info/METADATA +0 -291
- basic_memory-0.2.12.dist-info/RECORD +0 -78
- {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
basic_memory/config.py
CHANGED
|
@@ -1,57 +1,516 @@
|
|
|
1
1
|
"""Configuration management for basic-memory."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
3
7
|
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Literal, Optional, List, Tuple
|
|
4
9
|
|
|
5
|
-
from
|
|
10
|
+
from loguru import logger
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator
|
|
6
12
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
13
|
|
|
14
|
+
import basic_memory
|
|
15
|
+
from basic_memory.utils import setup_logging, generate_permalink
|
|
16
|
+
|
|
17
|
+
|
|
8
18
|
DATABASE_NAME = "memory.db"
|
|
19
|
+
APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
|
|
9
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"]
|
|
10
25
|
|
|
11
26
|
|
|
12
|
-
|
|
27
|
+
@dataclass
|
|
28
|
+
class ProjectConfig:
|
|
13
29
|
"""Configuration for a specific basic-memory project."""
|
|
14
30
|
|
|
15
|
-
|
|
16
|
-
home: Path
|
|
17
|
-
|
|
18
|
-
|
|
31
|
+
name: str
|
|
32
|
+
home: Path
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def project(self):
|
|
36
|
+
return self.name
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def project_url(self) -> str: # pragma: no cover
|
|
40
|
+
return f"/{generate_permalink(self.name)}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CloudProjectConfig(BaseModel):
|
|
44
|
+
"""Sync configuration for a cloud project.
|
|
45
|
+
|
|
46
|
+
This tracks the local working directory and sync state for a project
|
|
47
|
+
that is synced with Basic Memory Cloud.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
local_path: str = Field(description="Local working directory path for this cloud project")
|
|
51
|
+
last_sync: Optional[datetime] = Field(
|
|
52
|
+
default=None, description="Timestamp of last successful sync operation"
|
|
19
53
|
)
|
|
54
|
+
bisync_initialized: bool = Field(
|
|
55
|
+
default=False, description="Whether rclone bisync baseline has been established"
|
|
56
|
+
)
|
|
57
|
+
|
|
20
58
|
|
|
21
|
-
|
|
22
|
-
|
|
59
|
+
class BasicMemoryConfig(BaseSettings):
|
|
60
|
+
"""Pydantic model for Basic Memory global configuration."""
|
|
61
|
+
|
|
62
|
+
env: Environment = Field(default="dev", description="Environment name")
|
|
63
|
+
|
|
64
|
+
projects: Dict[str, str] = Field(
|
|
65
|
+
default_factory=lambda: {
|
|
66
|
+
"main": Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")).as_posix()
|
|
67
|
+
},
|
|
68
|
+
description="Mapping of project names to their filesystem paths",
|
|
69
|
+
)
|
|
70
|
+
default_project: str = Field(
|
|
71
|
+
default="main",
|
|
72
|
+
description="Name of the default project to use",
|
|
73
|
+
)
|
|
74
|
+
default_project_mode: bool = Field(
|
|
75
|
+
default=False,
|
|
76
|
+
description="When True, MCP tools automatically use default_project when no project parameter is specified. Enables simplified UX for single-project workflows.",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# overridden by ~/.basic-memory/config.json
|
|
80
|
+
log_level: str = "INFO"
|
|
23
81
|
|
|
24
82
|
# Watch service configuration
|
|
25
83
|
sync_delay: int = Field(
|
|
26
|
-
default=
|
|
84
|
+
default=1000, description="Milliseconds to wait after changes before syncing", gt=0
|
|
27
85
|
)
|
|
28
86
|
|
|
29
|
-
|
|
87
|
+
watch_project_reload_interval: int = Field(
|
|
88
|
+
default=30, description="Seconds between reloading project list in watch service", gt=0
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# update permalinks on move
|
|
92
|
+
update_permalinks_on_move: bool = Field(
|
|
93
|
+
default=False,
|
|
94
|
+
description="Whether to update permalinks when files are moved or renamed. default (False)",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
sync_changes: bool = Field(
|
|
98
|
+
default=True,
|
|
99
|
+
description="Whether to sync changes in real time. default (True)",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
sync_thread_pool_size: int = Field(
|
|
103
|
+
default=4,
|
|
104
|
+
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.",
|
|
105
|
+
gt=0,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
sync_max_concurrent_files: int = Field(
|
|
109
|
+
default=10,
|
|
110
|
+
description="Maximum number of files to process concurrently during sync. Limits memory usage on large projects (2000+ files). Lower values reduce memory consumption.",
|
|
111
|
+
gt=0,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
kebab_filenames: bool = Field(
|
|
115
|
+
default=False,
|
|
116
|
+
description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
disable_permalinks: bool = Field(
|
|
120
|
+
default=False,
|
|
121
|
+
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.",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
skip_initialization_sync: bool = Field(
|
|
125
|
+
default=False,
|
|
126
|
+
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Project path constraints
|
|
130
|
+
project_root: Optional[str] = Field(
|
|
131
|
+
default=None,
|
|
132
|
+
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).",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Cloud configuration
|
|
136
|
+
cloud_client_id: str = Field(
|
|
137
|
+
default="client_01K6KWQPW6J1M8VV7R3TZP5A6M",
|
|
138
|
+
description="OAuth client ID for Basic Memory Cloud",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
cloud_domain: str = Field(
|
|
142
|
+
default="https://eloquent-lotus-05.authkit.app",
|
|
143
|
+
description="AuthKit domain for Basic Memory Cloud",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
cloud_host: str = Field(
|
|
147
|
+
default_factory=lambda: os.getenv(
|
|
148
|
+
"BASIC_MEMORY_CLOUD_HOST", "https://cloud.basicmemory.com"
|
|
149
|
+
),
|
|
150
|
+
description="Basic Memory Cloud host URL",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
cloud_mode: bool = Field(
|
|
154
|
+
default=False,
|
|
155
|
+
description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
cloud_projects: Dict[str, CloudProjectConfig] = Field(
|
|
159
|
+
default_factory=dict,
|
|
160
|
+
description="Cloud project sync configuration mapping project names to their local paths and sync state",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def cloud_mode_enabled(self) -> bool:
|
|
165
|
+
"""Check if cloud mode is enabled.
|
|
166
|
+
|
|
167
|
+
Priority:
|
|
168
|
+
1. BASIC_MEMORY_CLOUD_MODE environment variable
|
|
169
|
+
2. Config file value (cloud_mode)
|
|
170
|
+
"""
|
|
171
|
+
env_value = os.environ.get("BASIC_MEMORY_CLOUD_MODE", "").lower()
|
|
172
|
+
if env_value in ("true", "1", "yes"):
|
|
173
|
+
return True
|
|
174
|
+
elif env_value in ("false", "0", "no"):
|
|
175
|
+
return False
|
|
176
|
+
# Fall back to config file value
|
|
177
|
+
return self.cloud_mode
|
|
30
178
|
|
|
31
179
|
model_config = SettingsConfigDict(
|
|
32
180
|
env_prefix="BASIC_MEMORY_",
|
|
33
181
|
extra="ignore",
|
|
34
|
-
env_file=".env",
|
|
35
|
-
env_file_encoding="utf-8",
|
|
36
182
|
)
|
|
37
183
|
|
|
184
|
+
def get_project_path(self, project_name: Optional[str] = None) -> Path: # pragma: no cover
|
|
185
|
+
"""Get the path for a specific project or the default project."""
|
|
186
|
+
name = project_name or self.default_project
|
|
187
|
+
|
|
188
|
+
if name not in self.projects:
|
|
189
|
+
raise ValueError(f"Project '{name}' not found in configuration")
|
|
190
|
+
|
|
191
|
+
return Path(self.projects[name])
|
|
192
|
+
|
|
193
|
+
def model_post_init(self, __context: Any) -> None:
|
|
194
|
+
"""Ensure configuration is valid after initialization."""
|
|
195
|
+
# Ensure main project exists
|
|
196
|
+
if "main" not in self.projects: # pragma: no cover
|
|
197
|
+
self.projects["main"] = (
|
|
198
|
+
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
|
|
199
|
+
).as_posix()
|
|
200
|
+
|
|
201
|
+
# Ensure default project is valid
|
|
202
|
+
if self.default_project not in self.projects: # pragma: no cover
|
|
203
|
+
self.default_project = "main"
|
|
204
|
+
|
|
38
205
|
@property
|
|
39
|
-
def
|
|
40
|
-
"""Get
|
|
41
|
-
|
|
42
|
-
|
|
206
|
+
def app_database_path(self) -> Path:
|
|
207
|
+
"""Get the path to the app-level database.
|
|
208
|
+
|
|
209
|
+
This is the single database that will store all knowledge data
|
|
210
|
+
across all projects.
|
|
211
|
+
"""
|
|
212
|
+
database_path = Path.home() / DATA_DIR_NAME / APP_DATABASE_NAME
|
|
213
|
+
if not database_path.exists(): # pragma: no cover
|
|
43
214
|
database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
215
|
database_path.touch()
|
|
45
216
|
return database_path
|
|
46
217
|
|
|
47
|
-
@
|
|
218
|
+
@property
|
|
219
|
+
def database_path(self) -> Path:
|
|
220
|
+
"""Get SQLite database path.
|
|
221
|
+
|
|
222
|
+
Rreturns the app-level database path
|
|
223
|
+
for backward compatibility in the codebase.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
# Load the app-level database path from the global config
|
|
227
|
+
config_manager = ConfigManager()
|
|
228
|
+
config = config_manager.load_config() # pragma: no cover
|
|
229
|
+
return config.app_database_path # pragma: no cover
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def project_list(self) -> List[ProjectConfig]: # pragma: no cover
|
|
233
|
+
"""Get all configured projects as ProjectConfig objects."""
|
|
234
|
+
return [ProjectConfig(name=name, home=Path(path)) for name, path in self.projects.items()]
|
|
235
|
+
|
|
236
|
+
@field_validator("projects")
|
|
48
237
|
@classmethod
|
|
49
|
-
def
|
|
238
|
+
def ensure_project_paths_exists(cls, v: Dict[str, str]) -> Dict[str, str]: # pragma: no cover
|
|
50
239
|
"""Ensure project path exists."""
|
|
51
|
-
|
|
52
|
-
|
|
240
|
+
for name, path_value in v.items():
|
|
241
|
+
path = Path(path_value)
|
|
242
|
+
if not Path(path).exists():
|
|
243
|
+
try:
|
|
244
|
+
path.mkdir(parents=True)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.error(f"Failed to create project path: {e}")
|
|
247
|
+
raise e
|
|
53
248
|
return v
|
|
54
249
|
|
|
250
|
+
@property
|
|
251
|
+
def data_dir_path(self):
|
|
252
|
+
return Path.home() / DATA_DIR_NAME
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# Module-level cache for configuration
|
|
256
|
+
_CONFIG_CACHE: Optional[BasicMemoryConfig] = None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class ConfigManager:
|
|
260
|
+
"""Manages Basic Memory configuration."""
|
|
261
|
+
|
|
262
|
+
def __init__(self) -> None:
|
|
263
|
+
"""Initialize the configuration manager."""
|
|
264
|
+
home = os.getenv("HOME", Path.home())
|
|
265
|
+
if isinstance(home, str):
|
|
266
|
+
home = Path(home)
|
|
267
|
+
|
|
268
|
+
# Allow override via environment variable
|
|
269
|
+
if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
|
|
270
|
+
self.config_dir = Path(config_dir)
|
|
271
|
+
else:
|
|
272
|
+
self.config_dir = home / DATA_DIR_NAME
|
|
273
|
+
|
|
274
|
+
self.config_file = self.config_dir / CONFIG_FILE_NAME
|
|
275
|
+
|
|
276
|
+
# Ensure config directory exists
|
|
277
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def config(self) -> BasicMemoryConfig:
|
|
281
|
+
"""Get configuration, loading it lazily if needed."""
|
|
282
|
+
return self.load_config()
|
|
283
|
+
|
|
284
|
+
def load_config(self) -> BasicMemoryConfig:
|
|
285
|
+
"""Load configuration from file or create default.
|
|
286
|
+
|
|
287
|
+
Environment variables take precedence over file config values,
|
|
288
|
+
following Pydantic Settings best practices.
|
|
289
|
+
|
|
290
|
+
Uses module-level cache for performance across ConfigManager instances.
|
|
291
|
+
"""
|
|
292
|
+
global _CONFIG_CACHE
|
|
293
|
+
|
|
294
|
+
# Return cached config if available
|
|
295
|
+
if _CONFIG_CACHE is not None:
|
|
296
|
+
return _CONFIG_CACHE
|
|
297
|
+
|
|
298
|
+
if self.config_file.exists():
|
|
299
|
+
try:
|
|
300
|
+
file_data = json.loads(self.config_file.read_text(encoding="utf-8"))
|
|
301
|
+
|
|
302
|
+
# First, create config from environment variables (Pydantic will read them)
|
|
303
|
+
# Then overlay with file data for fields that aren't set via env vars
|
|
304
|
+
# This ensures env vars take precedence
|
|
305
|
+
|
|
306
|
+
# Get env-based config fields that are actually set
|
|
307
|
+
env_config = BasicMemoryConfig()
|
|
308
|
+
env_dict = env_config.model_dump()
|
|
309
|
+
|
|
310
|
+
# Merge: file data as base, but only use it for fields not set by env
|
|
311
|
+
# We detect env-set fields by comparing to default values
|
|
312
|
+
merged_data = file_data.copy()
|
|
313
|
+
|
|
314
|
+
# For fields that have env var overrides, use those instead of file values
|
|
315
|
+
# The env_prefix is "BASIC_MEMORY_" so we check those
|
|
316
|
+
for field_name in BasicMemoryConfig.model_fields.keys():
|
|
317
|
+
env_var_name = f"BASIC_MEMORY_{field_name.upper()}"
|
|
318
|
+
if env_var_name in os.environ:
|
|
319
|
+
# Environment variable is set, use it
|
|
320
|
+
merged_data[field_name] = env_dict[field_name]
|
|
321
|
+
|
|
322
|
+
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
|
|
323
|
+
return _CONFIG_CACHE
|
|
324
|
+
except Exception as e: # pragma: no cover
|
|
325
|
+
logger.exception(f"Failed to load config: {e}")
|
|
326
|
+
raise e
|
|
327
|
+
else:
|
|
328
|
+
config = BasicMemoryConfig()
|
|
329
|
+
self.save_config(config)
|
|
330
|
+
return config
|
|
331
|
+
|
|
332
|
+
def save_config(self, config: BasicMemoryConfig) -> None:
|
|
333
|
+
"""Save configuration to file and invalidate cache."""
|
|
334
|
+
global _CONFIG_CACHE
|
|
335
|
+
save_basic_memory_config(self.config_file, config)
|
|
336
|
+
# Invalidate cache so next load_config() reads fresh data
|
|
337
|
+
_CONFIG_CACHE = None
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def projects(self) -> Dict[str, str]:
|
|
341
|
+
"""Get all configured projects."""
|
|
342
|
+
return self.config.projects.copy()
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def default_project(self) -> str:
|
|
346
|
+
"""Get the default project name."""
|
|
347
|
+
return self.config.default_project
|
|
348
|
+
|
|
349
|
+
def add_project(self, name: str, path: str) -> ProjectConfig:
|
|
350
|
+
"""Add a new project to the configuration."""
|
|
351
|
+
project_name, _ = self.get_project(name)
|
|
352
|
+
if project_name: # pragma: no cover
|
|
353
|
+
raise ValueError(f"Project '{name}' already exists")
|
|
354
|
+
|
|
355
|
+
# Ensure the path exists
|
|
356
|
+
project_path = Path(path)
|
|
357
|
+
project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
|
|
358
|
+
|
|
359
|
+
# Load config, modify it, and save it
|
|
360
|
+
config = self.load_config()
|
|
361
|
+
config.projects[name] = project_path.as_posix()
|
|
362
|
+
self.save_config(config)
|
|
363
|
+
return ProjectConfig(name=name, home=project_path)
|
|
364
|
+
|
|
365
|
+
def remove_project(self, name: str) -> None:
|
|
366
|
+
"""Remove a project from the configuration."""
|
|
367
|
+
|
|
368
|
+
project_name, path = self.get_project(name)
|
|
369
|
+
if not project_name: # pragma: no cover
|
|
370
|
+
raise ValueError(f"Project '{name}' not found")
|
|
371
|
+
|
|
372
|
+
# Load config, check, modify, and save
|
|
373
|
+
config = self.load_config()
|
|
374
|
+
if project_name == config.default_project: # pragma: no cover
|
|
375
|
+
raise ValueError(f"Cannot remove the default project '{name}'")
|
|
376
|
+
|
|
377
|
+
# Use the found project_name (which may differ from input name due to permalink matching)
|
|
378
|
+
del config.projects[project_name]
|
|
379
|
+
self.save_config(config)
|
|
380
|
+
|
|
381
|
+
def set_default_project(self, name: str) -> None:
|
|
382
|
+
"""Set the default project."""
|
|
383
|
+
project_name, path = self.get_project(name)
|
|
384
|
+
if not project_name: # pragma: no cover
|
|
385
|
+
raise ValueError(f"Project '{name}' not found")
|
|
386
|
+
|
|
387
|
+
# Load config, modify, and save
|
|
388
|
+
config = self.load_config()
|
|
389
|
+
config.default_project = project_name
|
|
390
|
+
self.save_config(config)
|
|
391
|
+
|
|
392
|
+
def get_project(self, name: str) -> Tuple[str, str] | Tuple[None, None]:
|
|
393
|
+
"""Look up a project from the configuration by name or permalink"""
|
|
394
|
+
project_permalink = generate_permalink(name)
|
|
395
|
+
app_config = self.config
|
|
396
|
+
for project_name, path in app_config.projects.items():
|
|
397
|
+
if project_permalink == generate_permalink(project_name):
|
|
398
|
+
return project_name, path
|
|
399
|
+
return None, None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
|
403
|
+
"""
|
|
404
|
+
Get the project configuration for the current session.
|
|
405
|
+
If project_name is provided, it will be used instead of the default project.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
actual_project_name = None
|
|
409
|
+
|
|
410
|
+
# load the config from file
|
|
411
|
+
config_manager = ConfigManager()
|
|
412
|
+
app_config = config_manager.load_config()
|
|
413
|
+
|
|
414
|
+
# Get project name from environment variable
|
|
415
|
+
os_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
|
|
416
|
+
if os_project_name: # pragma: no cover
|
|
417
|
+
logger.warning(
|
|
418
|
+
f"BASIC_MEMORY_PROJECT is not supported anymore. Set the default project in the config instead. Setting default project to {os_project_name}"
|
|
419
|
+
)
|
|
420
|
+
actual_project_name = project_name
|
|
421
|
+
# if the project_name is passed in, use it
|
|
422
|
+
elif not project_name:
|
|
423
|
+
# use default
|
|
424
|
+
actual_project_name = app_config.default_project
|
|
425
|
+
else: # pragma: no cover
|
|
426
|
+
actual_project_name = project_name
|
|
427
|
+
|
|
428
|
+
# the config contains a dict[str,str] of project names and absolute paths
|
|
429
|
+
assert actual_project_name is not None, "actual_project_name cannot be None"
|
|
430
|
+
|
|
431
|
+
project_permalink = generate_permalink(actual_project_name)
|
|
432
|
+
|
|
433
|
+
for name, path in app_config.projects.items():
|
|
434
|
+
if project_permalink == generate_permalink(name):
|
|
435
|
+
return ProjectConfig(name=name, home=Path(path))
|
|
436
|
+
|
|
437
|
+
# otherwise raise error
|
|
438
|
+
raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
|
|
442
|
+
"""Save configuration to file."""
|
|
443
|
+
try:
|
|
444
|
+
# Use model_dump with mode='json' to serialize datetime objects properly
|
|
445
|
+
config_dict = config.model_dump(mode="json")
|
|
446
|
+
file_path.write_text(json.dumps(config_dict, indent=2))
|
|
447
|
+
except Exception as e: # pragma: no cover
|
|
448
|
+
logger.error(f"Failed to save config: {e}")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# setup logging to a single log file in user home directory
|
|
452
|
+
user_home = Path.home()
|
|
453
|
+
log_dir = user_home / DATA_DIR_NAME
|
|
454
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# Process info for logging
|
|
458
|
+
def get_process_name(): # pragma: no cover
|
|
459
|
+
"""
|
|
460
|
+
get the type of process for logging
|
|
461
|
+
"""
|
|
462
|
+
import sys
|
|
463
|
+
|
|
464
|
+
if "sync" in sys.argv:
|
|
465
|
+
return "sync"
|
|
466
|
+
elif "mcp" in sys.argv:
|
|
467
|
+
return "mcp"
|
|
468
|
+
elif "cli" in sys.argv:
|
|
469
|
+
return "cli"
|
|
470
|
+
else:
|
|
471
|
+
return "api"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
process_name = get_process_name()
|
|
475
|
+
|
|
476
|
+
# Global flag to track if logging has been set up
|
|
477
|
+
_LOGGING_SETUP = False
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# Logging
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def setup_basic_memory_logging(): # pragma: no cover
|
|
484
|
+
"""Set up logging for basic-memory, ensuring it only happens once."""
|
|
485
|
+
global _LOGGING_SETUP
|
|
486
|
+
if _LOGGING_SETUP:
|
|
487
|
+
# We can't log before logging is set up
|
|
488
|
+
# print("Skipping duplicate logging setup")
|
|
489
|
+
return
|
|
490
|
+
|
|
491
|
+
# Check for console logging environment variable - accept more truthy values
|
|
492
|
+
console_logging_env = os.getenv("BASIC_MEMORY_CONSOLE_LOGGING", "false").lower()
|
|
493
|
+
console_logging = console_logging_env in ("true", "1", "yes", "on")
|
|
494
|
+
|
|
495
|
+
# Check for log level environment variable first, fall back to config
|
|
496
|
+
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL")
|
|
497
|
+
if not log_level:
|
|
498
|
+
config_manager = ConfigManager()
|
|
499
|
+
log_level = config_manager.config.log_level
|
|
500
|
+
|
|
501
|
+
config_manager = ConfigManager()
|
|
502
|
+
config = get_project_config()
|
|
503
|
+
setup_logging(
|
|
504
|
+
env=config_manager.config.env,
|
|
505
|
+
home_dir=user_home, # Use user home for logs
|
|
506
|
+
log_level=log_level,
|
|
507
|
+
log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
|
|
508
|
+
console=console_logging,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
logger.info(f"Basic Memory {basic_memory.__version__} (Project: {config.project})")
|
|
512
|
+
_LOGGING_SETUP = True
|
|
513
|
+
|
|
55
514
|
|
|
56
|
-
#
|
|
57
|
-
|
|
515
|
+
# Set up logging
|
|
516
|
+
setup_basic_memory_logging()
|