-
Notifications
You must be signed in to change notification settings - Fork 188
Expand file tree
/
Copy pathconfig.py
More file actions
418 lines (325 loc) · 13.8 KB
/
config.py
File metadata and controls
418 lines (325 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
"""Configuration management for basic-memory."""
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Literal, Optional, List, Tuple
from loguru import logger
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
import basic_memory
from basic_memory.utils import setup_logging, generate_permalink
DATABASE_NAME = "memory.db"
APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
DATA_DIR_NAME = ".basic-memory"
CONFIG_FILE_NAME = "config.json"
WATCH_STATUS_JSON = "watch-status.json"
Environment = Literal["test", "dev", "user"]
@dataclass
class ProjectConfig:
"""Configuration for a specific basic-memory project."""
name: str
home: Path
@property
def project(self):
return self.name
@property
def project_url(self) -> str: # pragma: no cover
return f"/{generate_permalink(self.name)}"
class BasicMemoryConfig(BaseSettings):
"""Pydantic model for Basic Memory global configuration."""
env: Environment = Field(default="dev", description="Environment name")
projects: Dict[str, str] = Field(
default_factory=lambda: {
"main": Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")).as_posix()
},
description="Mapping of project names to their filesystem paths",
)
default_project: str = Field(
default="main",
description="Name of the default project to use",
)
default_project_mode: bool = Field(
default=False,
description="When True, MCP tools automatically use default_project when no project parameter is specified. Enables simplified UX for single-project workflows.",
)
# overridden by ~/.basic-memory/config.json
log_level: str = "INFO"
# Watch service configuration
sync_delay: int = Field(
default=1000, description="Milliseconds to wait after changes before syncing", gt=0
)
watch_project_reload_interval: int = Field(
default=30, description="Seconds between reloading project list in watch service", gt=0
)
# update permalinks on move
update_permalinks_on_move: bool = Field(
default=False,
description="Whether to update permalinks when files are moved or renamed. default (False)",
)
sync_changes: bool = Field(
default=True,
description="Whether to sync changes in real time. default (True)",
)
sync_thread_pool_size: int = Field(
default=4,
description="Size of thread pool for file I/O operations in sync service",
gt=0,
)
kebab_filenames: bool = Field(
default=False,
description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks",
)
# API connection configuration
api_url: Optional[str] = Field(
default=None,
description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.",
)
# Cloud configuration
cloud_client_id: str = Field(
default="client_01K4DGBWAZWP83N3H8VVEMRX6W",
description="OAuth client ID for Basic Memory Cloud",
)
cloud_domain: str = Field(
default="https://eloquent-lotus-05.authkit.app",
description="AuthKit domain for Basic Memory Cloud",
)
cloud_host: str = Field(
default="https://cloud.basicmemory.com",
description="Basic Memory Cloud proxy host URL",
)
model_config = SettingsConfigDict(
env_prefix="BASIC_MEMORY_",
extra="ignore",
env_file=".env",
env_file_encoding="utf-8",
)
def get_project_path(self, project_name: Optional[str] = None) -> Path: # pragma: no cover
"""Get the path for a specific project or the default project."""
name = project_name or self.default_project
if name not in self.projects:
raise ValueError(f"Project '{name}' not found in configuration")
return Path(self.projects[name])
def model_post_init(self, __context: Any) -> None:
"""Ensure configuration is valid after initialization."""
# Ensure main project exists
if "main" not in self.projects: # pragma: no cover
self.projects["main"] = (
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
).as_posix()
# Ensure default project is valid
if self.default_project not in self.projects: # pragma: no cover
self.default_project = "main"
@property
def app_database_path(self) -> Path:
"""Get the path to the app-level database.
This is the single database that will store all knowledge data
across all projects.
"""
database_path = Path.home() / DATA_DIR_NAME / APP_DATABASE_NAME
if not database_path.exists(): # pragma: no cover
database_path.parent.mkdir(parents=True, exist_ok=True)
database_path.touch()
return database_path
@property
def database_path(self) -> Path:
"""Get SQLite database path.
Rreturns the app-level database path
for backward compatibility in the codebase.
"""
# Load the app-level database path from the global config
config_manager = ConfigManager()
config = config_manager.load_config() # pragma: no cover
return config.app_database_path # pragma: no cover
@property
def project_list(self) -> List[ProjectConfig]: # pragma: no cover
"""Get all configured projects as ProjectConfig objects."""
return [ProjectConfig(name=name, home=Path(path)) for name, path in self.projects.items()]
@field_validator("projects")
@classmethod
def ensure_project_paths_exists(cls, v: Dict[str, str]) -> Dict[str, str]: # pragma: no cover
"""Ensure project path exists."""
for name, path_value in v.items():
path = Path(path_value)
if not Path(path).exists():
try:
path.mkdir(parents=True)
except Exception as e:
logger.error(f"Failed to create project path: {e}")
raise e
return v
@property
def data_dir_path(self):
return Path.home() / DATA_DIR_NAME
class ConfigManager:
"""Manages Basic Memory configuration."""
def __init__(self) -> None:
"""Initialize the configuration manager."""
home = os.getenv("HOME", Path.home())
if isinstance(home, str):
home = Path(home)
self.config_dir = home / DATA_DIR_NAME
self.config_file = self.config_dir / CONFIG_FILE_NAME
# Ensure config directory exists
self.config_dir.mkdir(parents=True, exist_ok=True)
@property
def config(self) -> BasicMemoryConfig:
"""Get configuration, loading it lazily if needed."""
return self.load_config()
def load_config(self) -> BasicMemoryConfig:
"""Load configuration from file or create default."""
if self.config_file.exists():
try:
data = json.loads(self.config_file.read_text(encoding="utf-8"))
return BasicMemoryConfig(**data)
except Exception as e: # pragma: no cover
logger.exception(f"Failed to load config: {e}")
raise e
else:
config = BasicMemoryConfig()
self.save_config(config)
return config
def save_config(self, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
save_basic_memory_config(self.config_file, config)
@property
def projects(self) -> Dict[str, str]:
"""Get all configured projects."""
return self.config.projects.copy()
@property
def default_project(self) -> str:
"""Get the default project name."""
return self.config.default_project
def add_project(self, name: str, path: str) -> ProjectConfig:
"""Add a new project to the configuration."""
project_name, _ = self.get_project(name)
if project_name: # pragma: no cover
raise ValueError(f"Project '{name}' already exists")
# Ensure the path exists
project_path = Path(path)
project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
# Load config, modify it, and save it
config = self.load_config()
config.projects[name] = project_path.as_posix()
self.save_config(config)
return ProjectConfig(name=name, home=project_path)
def remove_project(self, name: str) -> None:
"""Remove a project from the configuration."""
project_name, path = self.get_project(name)
if not project_name: # pragma: no cover
raise ValueError(f"Project '{name}' not found")
# Load config, check, modify, and save
config = self.load_config()
if project_name == config.default_project: # pragma: no cover
raise ValueError(f"Cannot remove the default project '{name}'")
del config.projects[name]
self.save_config(config)
def set_default_project(self, name: str) -> None:
"""Set the default project."""
project_name, path = self.get_project(name)
if not project_name: # pragma: no cover
raise ValueError(f"Project '{name}' not found")
# Load config, modify, and save
config = self.load_config()
config.default_project = project_name
self.save_config(config)
def get_project(self, name: str) -> Tuple[str, str] | Tuple[None, None]:
"""Look up a project from the configuration by name or permalink"""
project_permalink = generate_permalink(name)
app_config = self.config
for project_name, path in app_config.projects.items():
if project_permalink == generate_permalink(project_name):
return project_name, path
return None, None
def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
"""
Get the project configuration for the current session.
If project_name is provided, it will be used instead of the default project.
"""
actual_project_name = None
# load the config from file
config_manager = ConfigManager()
app_config = config_manager.load_config()
# Get project name from environment variable
os_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
if os_project_name: # pragma: no cover
logger.warning(
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}"
)
actual_project_name = project_name
# if the project_name is passed in, use it
elif not project_name:
# use default
actual_project_name = app_config.default_project
else: # pragma: no cover
actual_project_name = project_name
# the config contains a dict[str,str] of project names and absolute paths
assert actual_project_name is not None, "actual_project_name cannot be None"
project_permalink = generate_permalink(actual_project_name)
for name, path in app_config.projects.items():
if project_permalink == generate_permalink(name):
return ProjectConfig(name=name, home=Path(path))
# otherwise raise error
raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover
def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
try:
file_path.write_text(json.dumps(config.model_dump(), indent=2))
except Exception as e: # pragma: no cover
logger.error(f"Failed to save config: {e}")
def update_current_project(project_name: str) -> None:
"""Update the global config to use a different project.
This is used by the CLI when --project flag is specified.
"""
global config
config = get_project_config(project_name) # pragma: no cover
# setup logging to a single log file in user home directory
user_home = Path.home()
log_dir = user_home / DATA_DIR_NAME
log_dir.mkdir(parents=True, exist_ok=True)
# Process info for logging
def get_process_name(): # pragma: no cover
"""
get the type of process for logging
"""
import sys
if "sync" in sys.argv:
return "sync"
elif "mcp" in sys.argv:
return "mcp"
elif "cli" in sys.argv:
return "cli"
else:
return "api"
process_name = get_process_name()
# Global flag to track if logging has been set up
_LOGGING_SETUP = False
# Logging
def setup_basic_memory_logging(): # pragma: no cover
"""Set up logging for basic-memory, ensuring it only happens once."""
global _LOGGING_SETUP
if _LOGGING_SETUP:
# We can't log before logging is set up
# print("Skipping duplicate logging setup")
return
# Check for console logging environment variable - accept more truthy values
console_logging_env = os.getenv("BASIC_MEMORY_CONSOLE_LOGGING", "false").lower()
console_logging = console_logging_env in ("true", "1", "yes", "on")
# Check for log level environment variable first, fall back to config
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL")
if not log_level:
config_manager = ConfigManager()
log_level = config_manager.config.log_level
config_manager = ConfigManager()
config = get_project_config()
setup_logging(
env=config_manager.config.env,
home_dir=user_home, # Use user home for logs
log_level=log_level,
log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
console=console_logging,
)
logger.info(f"Basic Memory {basic_memory.__version__} (Project: {config.project})")
_LOGGING_SETUP = True
# Set up logging
setup_basic_memory_logging()