basic-memory 0.14.4__py3-none-any.whl → 0.15.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 +1 -1
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
- basic_memory/api/app.py +10 -4
- basic_memory/api/routers/directory_router.py +23 -2
- basic_memory/api/routers/knowledge_router.py +25 -8
- basic_memory/api/routers/project_router.py +100 -4
- basic_memory/cli/app.py +9 -28
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/cloud/__init__.py +5 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
- basic_memory/cli/commands/cloud/core_commands.py +288 -0
- basic_memory/cli/commands/cloud/mount_commands.py +295 -0
- basic_memory/cli/commands/cloud/rclone_config.py +288 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
- basic_memory/cli/commands/command_utils.py +43 -0
- basic_memory/cli/commands/import_memory_json.py +0 -4
- basic_memory/cli/commands/mcp.py +77 -60
- basic_memory/cli/commands/project.py +154 -152
- basic_memory/cli/commands/status.py +25 -22
- basic_memory/cli/commands/sync.py +45 -228
- basic_memory/cli/commands/tool.py +87 -16
- basic_memory/cli/main.py +1 -0
- basic_memory/config.py +131 -21
- basic_memory/db.py +104 -3
- basic_memory/deps.py +27 -8
- basic_memory/file_utils.py +37 -13
- basic_memory/ignore_utils.py +295 -0
- basic_memory/markdown/plugins.py +9 -7
- basic_memory/mcp/async_client.py +124 -14
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
- basic_memory/mcp/prompts/continue_conversation.py +17 -16
- basic_memory/mcp/prompts/recent_activity.py +116 -32
- basic_memory/mcp/prompts/search.py +13 -12
- basic_memory/mcp/prompts/utils.py +11 -4
- basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
- basic_memory/mcp/resources/project_info.py +27 -11
- basic_memory/mcp/server.py +0 -37
- basic_memory/mcp/tools/__init__.py +5 -6
- basic_memory/mcp/tools/build_context.py +67 -56
- basic_memory/mcp/tools/canvas.py +38 -26
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +81 -47
- basic_memory/mcp/tools/edit_note.py +155 -138
- basic_memory/mcp/tools/list_directory.py +112 -99
- basic_memory/mcp/tools/move_note.py +181 -101
- basic_memory/mcp/tools/project_management.py +113 -277
- basic_memory/mcp/tools/read_content.py +91 -74
- basic_memory/mcp/tools/read_note.py +152 -115
- basic_memory/mcp/tools/recent_activity.py +471 -68
- basic_memory/mcp/tools/search.py +105 -92
- basic_memory/mcp/tools/sync_status.py +136 -130
- basic_memory/mcp/tools/utils.py +4 -0
- basic_memory/mcp/tools/view_note.py +44 -33
- basic_memory/mcp/tools/write_note.py +151 -90
- basic_memory/models/knowledge.py +12 -6
- basic_memory/models/project.py +6 -2
- basic_memory/repository/entity_repository.py +89 -82
- basic_memory/repository/relation_repository.py +13 -0
- basic_memory/repository/repository.py +18 -5
- basic_memory/repository/search_repository.py +46 -2
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +39 -11
- basic_memory/schemas/cloud.py +46 -0
- basic_memory/schemas/memory.py +90 -21
- basic_memory/schemas/project_info.py +9 -10
- basic_memory/schemas/sync_report.py +48 -0
- basic_memory/services/context_service.py +25 -11
- basic_memory/services/directory_service.py +124 -3
- basic_memory/services/entity_service.py +100 -48
- basic_memory/services/initialization.py +30 -11
- basic_memory/services/project_service.py +101 -24
- basic_memory/services/search_service.py +16 -8
- basic_memory/sync/sync_service.py +173 -34
- basic_memory/sync/watch_service.py +101 -40
- basic_memory/utils.py +14 -4
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
- basic_memory-0.15.1.dist-info/RECORD +146 -0
- basic_memory/mcp/project_session.py +0 -120
- basic_memory-0.14.4.dist-info/RECORD +0 -133
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
basic_memory/config.py
CHANGED
|
@@ -54,6 +54,10 @@ class BasicMemoryConfig(BaseSettings):
|
|
|
54
54
|
default="main",
|
|
55
55
|
description="Name of the default project to use",
|
|
56
56
|
)
|
|
57
|
+
default_project_mode: bool = Field(
|
|
58
|
+
default=False,
|
|
59
|
+
description="When True, MCP tools automatically use default_project when no project parameter is specified. Enables simplified UX for single-project workflows.",
|
|
60
|
+
)
|
|
57
61
|
|
|
58
62
|
# overridden by ~/.basic-memory/config.json
|
|
59
63
|
log_level: str = "INFO"
|
|
@@ -63,6 +67,10 @@ class BasicMemoryConfig(BaseSettings):
|
|
|
63
67
|
default=1000, description="Milliseconds to wait after changes before syncing", gt=0
|
|
64
68
|
)
|
|
65
69
|
|
|
70
|
+
watch_project_reload_interval: int = Field(
|
|
71
|
+
default=30, description="Seconds between reloading project list in watch service", gt=0
|
|
72
|
+
)
|
|
73
|
+
|
|
66
74
|
# update permalinks on move
|
|
67
75
|
update_permalinks_on_move: bool = Field(
|
|
68
76
|
default=False,
|
|
@@ -74,22 +82,83 @@ class BasicMemoryConfig(BaseSettings):
|
|
|
74
82
|
description="Whether to sync changes in real time. default (True)",
|
|
75
83
|
)
|
|
76
84
|
|
|
85
|
+
sync_thread_pool_size: int = Field(
|
|
86
|
+
default=4,
|
|
87
|
+
description="Size of thread pool for file I/O operations in sync service",
|
|
88
|
+
gt=0,
|
|
89
|
+
)
|
|
90
|
+
|
|
77
91
|
kebab_filenames: bool = Field(
|
|
78
92
|
default=False,
|
|
79
93
|
description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks",
|
|
80
94
|
)
|
|
81
95
|
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
disable_permalinks: bool = Field(
|
|
97
|
+
default=False,
|
|
98
|
+
description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
skip_initialization_sync: bool = Field(
|
|
102
|
+
default=False,
|
|
103
|
+
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Project path constraints
|
|
107
|
+
project_root: Optional[str] = Field(
|
|
84
108
|
default=None,
|
|
85
|
-
description="
|
|
109
|
+
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).",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Cloud configuration
|
|
113
|
+
cloud_client_id: str = Field(
|
|
114
|
+
default="client_01K6KWQPW6J1M8VV7R3TZP5A6M",
|
|
115
|
+
description="OAuth client ID for Basic Memory Cloud",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
cloud_domain: str = Field(
|
|
119
|
+
default="https://eloquent-lotus-05.authkit.app",
|
|
120
|
+
description="AuthKit domain for Basic Memory Cloud",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
cloud_host: str = Field(
|
|
124
|
+
default_factory=lambda: os.getenv(
|
|
125
|
+
"BASIC_MEMORY_CLOUD_HOST", "https://cloud.basicmemory.com"
|
|
126
|
+
),
|
|
127
|
+
description="Basic Memory Cloud host URL",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
cloud_mode: bool = Field(
|
|
131
|
+
default=False,
|
|
132
|
+
description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def cloud_mode_enabled(self) -> bool:
|
|
137
|
+
"""Check if cloud mode is enabled.
|
|
138
|
+
|
|
139
|
+
Priority:
|
|
140
|
+
1. BASIC_MEMORY_CLOUD_MODE environment variable
|
|
141
|
+
2. Config file value (cloud_mode)
|
|
142
|
+
"""
|
|
143
|
+
env_value = os.environ.get("BASIC_MEMORY_CLOUD_MODE", "").lower()
|
|
144
|
+
if env_value in ("true", "1", "yes"):
|
|
145
|
+
return True
|
|
146
|
+
elif env_value in ("false", "0", "no"):
|
|
147
|
+
return False
|
|
148
|
+
# Fall back to config file value
|
|
149
|
+
return self.cloud_mode
|
|
150
|
+
|
|
151
|
+
bisync_config: Dict[str, Any] = Field(
|
|
152
|
+
default_factory=lambda: {
|
|
153
|
+
"profile": "balanced",
|
|
154
|
+
"sync_dir": str(Path.home() / "basic-memory-cloud-sync"),
|
|
155
|
+
},
|
|
156
|
+
description="Bisync configuration for cloud sync",
|
|
86
157
|
)
|
|
87
158
|
|
|
88
159
|
model_config = SettingsConfigDict(
|
|
89
160
|
env_prefix="BASIC_MEMORY_",
|
|
90
161
|
extra="ignore",
|
|
91
|
-
env_file=".env",
|
|
92
|
-
env_file_encoding="utf-8",
|
|
93
162
|
)
|
|
94
163
|
|
|
95
164
|
def get_project_path(self, project_name: Optional[str] = None) -> Path: # pragma: no cover
|
|
@@ -158,6 +227,14 @@ class BasicMemoryConfig(BaseSettings):
|
|
|
158
227
|
raise e
|
|
159
228
|
return v
|
|
160
229
|
|
|
230
|
+
@property
|
|
231
|
+
def data_dir_path(self):
|
|
232
|
+
return Path.home() / DATA_DIR_NAME
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# Module-level cache for configuration
|
|
236
|
+
_CONFIG_CACHE: Optional[BasicMemoryConfig] = None
|
|
237
|
+
|
|
161
238
|
|
|
162
239
|
class ConfigManager:
|
|
163
240
|
"""Manages Basic Memory configuration."""
|
|
@@ -168,7 +245,12 @@ class ConfigManager:
|
|
|
168
245
|
if isinstance(home, str):
|
|
169
246
|
home = Path(home)
|
|
170
247
|
|
|
171
|
-
|
|
248
|
+
# Allow override via environment variable
|
|
249
|
+
if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
|
|
250
|
+
self.config_dir = Path(config_dir)
|
|
251
|
+
else:
|
|
252
|
+
self.config_dir = home / DATA_DIR_NAME
|
|
253
|
+
|
|
172
254
|
self.config_file = self.config_dir / CONFIG_FILE_NAME
|
|
173
255
|
|
|
174
256
|
# Ensure config directory exists
|
|
@@ -180,12 +262,45 @@ class ConfigManager:
|
|
|
180
262
|
return self.load_config()
|
|
181
263
|
|
|
182
264
|
def load_config(self) -> BasicMemoryConfig:
|
|
183
|
-
"""Load configuration from file or create default.
|
|
265
|
+
"""Load configuration from file or create default.
|
|
266
|
+
|
|
267
|
+
Environment variables take precedence over file config values,
|
|
268
|
+
following Pydantic Settings best practices.
|
|
269
|
+
|
|
270
|
+
Uses module-level cache for performance across ConfigManager instances.
|
|
271
|
+
"""
|
|
272
|
+
global _CONFIG_CACHE
|
|
273
|
+
|
|
274
|
+
# Return cached config if available
|
|
275
|
+
if _CONFIG_CACHE is not None:
|
|
276
|
+
return _CONFIG_CACHE
|
|
184
277
|
|
|
185
278
|
if self.config_file.exists():
|
|
186
279
|
try:
|
|
187
|
-
|
|
188
|
-
|
|
280
|
+
file_data = json.loads(self.config_file.read_text(encoding="utf-8"))
|
|
281
|
+
|
|
282
|
+
# First, create config from environment variables (Pydantic will read them)
|
|
283
|
+
# Then overlay with file data for fields that aren't set via env vars
|
|
284
|
+
# This ensures env vars take precedence
|
|
285
|
+
|
|
286
|
+
# Get env-based config fields that are actually set
|
|
287
|
+
env_config = BasicMemoryConfig()
|
|
288
|
+
env_dict = env_config.model_dump()
|
|
289
|
+
|
|
290
|
+
# Merge: file data as base, but only use it for fields not set by env
|
|
291
|
+
# We detect env-set fields by comparing to default values
|
|
292
|
+
merged_data = file_data.copy()
|
|
293
|
+
|
|
294
|
+
# For fields that have env var overrides, use those instead of file values
|
|
295
|
+
# The env_prefix is "BASIC_MEMORY_" so we check those
|
|
296
|
+
for field_name in BasicMemoryConfig.model_fields.keys():
|
|
297
|
+
env_var_name = f"BASIC_MEMORY_{field_name.upper()}"
|
|
298
|
+
if env_var_name in os.environ:
|
|
299
|
+
# Environment variable is set, use it
|
|
300
|
+
merged_data[field_name] = env_dict[field_name]
|
|
301
|
+
|
|
302
|
+
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
|
|
303
|
+
return _CONFIG_CACHE
|
|
189
304
|
except Exception as e: # pragma: no cover
|
|
190
305
|
logger.exception(f"Failed to load config: {e}")
|
|
191
306
|
raise e
|
|
@@ -195,8 +310,11 @@ class ConfigManager:
|
|
|
195
310
|
return config
|
|
196
311
|
|
|
197
312
|
def save_config(self, config: BasicMemoryConfig) -> None:
|
|
198
|
-
"""Save configuration to file."""
|
|
313
|
+
"""Save configuration to file and invalidate cache."""
|
|
314
|
+
global _CONFIG_CACHE
|
|
199
315
|
save_basic_memory_config(self.config_file, config)
|
|
316
|
+
# Invalidate cache so next load_config() reads fresh data
|
|
317
|
+
_CONFIG_CACHE = None
|
|
200
318
|
|
|
201
319
|
@property
|
|
202
320
|
def projects(self) -> Dict[str, str]:
|
|
@@ -236,7 +354,8 @@ class ConfigManager:
|
|
|
236
354
|
if project_name == config.default_project: # pragma: no cover
|
|
237
355
|
raise ValueError(f"Cannot remove the default project '{name}'")
|
|
238
356
|
|
|
239
|
-
|
|
357
|
+
# Use the found project_name (which may differ from input name due to permalink matching)
|
|
358
|
+
del config.projects[project_name]
|
|
240
359
|
self.save_config(config)
|
|
241
360
|
|
|
242
361
|
def set_default_project(self, name: str) -> None:
|
|
@@ -276,7 +395,7 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
|
|
276
395
|
os_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
|
|
277
396
|
if os_project_name: # pragma: no cover
|
|
278
397
|
logger.warning(
|
|
279
|
-
f"BASIC_MEMORY_PROJECT is not supported anymore.
|
|
398
|
+
f"BASIC_MEMORY_PROJECT is not supported anymore. Set the default project in the config instead. Setting default project to {os_project_name}"
|
|
280
399
|
)
|
|
281
400
|
actual_project_name = project_name
|
|
282
401
|
# if the project_name is passed in, use it
|
|
@@ -307,15 +426,6 @@ def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None
|
|
|
307
426
|
logger.error(f"Failed to save config: {e}")
|
|
308
427
|
|
|
309
428
|
|
|
310
|
-
def update_current_project(project_name: str) -> None:
|
|
311
|
-
"""Update the global config to use a different project.
|
|
312
|
-
|
|
313
|
-
This is used by the CLI when --project flag is specified.
|
|
314
|
-
"""
|
|
315
|
-
global config
|
|
316
|
-
config = get_project_config(project_name) # pragma: no cover
|
|
317
|
-
|
|
318
|
-
|
|
319
429
|
# setup logging to a single log file in user home directory
|
|
320
430
|
user_home = Path.home()
|
|
321
431
|
log_dir = user_home / DATA_DIR_NAME
|
basic_memory/db.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import os
|
|
2
3
|
from contextlib import asynccontextmanager
|
|
3
4
|
from enum import Enum, auto
|
|
4
5
|
from pathlib import Path
|
|
@@ -9,7 +10,7 @@ from alembic import command
|
|
|
9
10
|
from alembic.config import Config
|
|
10
11
|
|
|
11
12
|
from loguru import logger
|
|
12
|
-
from sqlalchemy import text
|
|
13
|
+
from sqlalchemy import text, event
|
|
13
14
|
from sqlalchemy.ext.asyncio import (
|
|
14
15
|
create_async_engine,
|
|
15
16
|
async_sessionmaker,
|
|
@@ -17,6 +18,7 @@ from sqlalchemy.ext.asyncio import (
|
|
|
17
18
|
AsyncEngine,
|
|
18
19
|
async_scoped_session,
|
|
19
20
|
)
|
|
21
|
+
from sqlalchemy.pool import NullPool
|
|
20
22
|
|
|
21
23
|
from basic_memory.repository.search_repository import SearchRepository
|
|
22
24
|
|
|
@@ -73,13 +75,77 @@ async def scoped_session(
|
|
|
73
75
|
await factory.remove()
|
|
74
76
|
|
|
75
77
|
|
|
78
|
+
def _configure_sqlite_connection(dbapi_conn, enable_wal: bool = True) -> None:
|
|
79
|
+
"""Configure SQLite connection with WAL mode and optimizations.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
dbapi_conn: Database API connection object
|
|
83
|
+
enable_wal: Whether to enable WAL mode (should be False for in-memory databases)
|
|
84
|
+
"""
|
|
85
|
+
cursor = dbapi_conn.cursor()
|
|
86
|
+
try:
|
|
87
|
+
# Enable WAL mode for better concurrency (not supported for in-memory databases)
|
|
88
|
+
if enable_wal:
|
|
89
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
90
|
+
# Set busy timeout to handle locked databases
|
|
91
|
+
cursor.execute("PRAGMA busy_timeout=10000") # 10 seconds
|
|
92
|
+
# Optimize for performance
|
|
93
|
+
cursor.execute("PRAGMA synchronous=NORMAL")
|
|
94
|
+
cursor.execute("PRAGMA cache_size=-64000") # 64MB cache
|
|
95
|
+
cursor.execute("PRAGMA temp_store=MEMORY")
|
|
96
|
+
# Windows-specific optimizations
|
|
97
|
+
if os.name == "nt":
|
|
98
|
+
cursor.execute("PRAGMA locking_mode=NORMAL") # Ensure normal locking on Windows
|
|
99
|
+
except Exception as e:
|
|
100
|
+
# Log but don't fail - some PRAGMAs may not be supported
|
|
101
|
+
logger.warning(f"Failed to configure SQLite connection: {e}")
|
|
102
|
+
finally:
|
|
103
|
+
cursor.close()
|
|
104
|
+
|
|
105
|
+
|
|
76
106
|
def _create_engine_and_session(
|
|
77
107
|
db_path: Path, db_type: DatabaseType = DatabaseType.FILESYSTEM
|
|
78
108
|
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
|
79
109
|
"""Internal helper to create engine and session maker."""
|
|
80
110
|
db_url = DatabaseType.get_db_url(db_path, db_type)
|
|
81
111
|
logger.debug(f"Creating engine for db_url: {db_url}")
|
|
82
|
-
|
|
112
|
+
|
|
113
|
+
# Configure connection args with Windows-specific settings
|
|
114
|
+
connect_args: dict[str, bool | float | None] = {"check_same_thread": False}
|
|
115
|
+
|
|
116
|
+
# Add Windows-specific parameters to improve reliability
|
|
117
|
+
if os.name == "nt": # Windows
|
|
118
|
+
connect_args.update(
|
|
119
|
+
{
|
|
120
|
+
"timeout": 30.0, # Increase timeout to 30 seconds for Windows
|
|
121
|
+
"isolation_level": None, # Use autocommit mode
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
# Use NullPool for Windows filesystem databases to avoid connection pooling issues
|
|
125
|
+
# Important: Do NOT use NullPool for in-memory databases as it will destroy the database
|
|
126
|
+
# between connections
|
|
127
|
+
if db_type == DatabaseType.FILESYSTEM:
|
|
128
|
+
engine = create_async_engine(
|
|
129
|
+
db_url,
|
|
130
|
+
connect_args=connect_args,
|
|
131
|
+
poolclass=NullPool, # Disable connection pooling on Windows
|
|
132
|
+
echo=False,
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
# In-memory databases need connection pooling to maintain state
|
|
136
|
+
engine = create_async_engine(db_url, connect_args=connect_args)
|
|
137
|
+
else:
|
|
138
|
+
engine = create_async_engine(db_url, connect_args=connect_args)
|
|
139
|
+
|
|
140
|
+
# Enable WAL mode for better concurrency and reliability
|
|
141
|
+
# Note: WAL mode is not supported for in-memory databases
|
|
142
|
+
enable_wal = db_type != DatabaseType.MEMORY
|
|
143
|
+
|
|
144
|
+
@event.listens_for(engine.sync_engine, "connect")
|
|
145
|
+
def enable_wal_mode(dbapi_conn, connection_record):
|
|
146
|
+
"""Enable WAL mode on each connection."""
|
|
147
|
+
_configure_sqlite_connection(dbapi_conn, enable_wal=enable_wal)
|
|
148
|
+
|
|
83
149
|
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
84
150
|
return engine, session_maker
|
|
85
151
|
|
|
@@ -140,7 +206,42 @@ async def engine_session_factory(
|
|
|
140
206
|
db_url = DatabaseType.get_db_url(db_path, db_type)
|
|
141
207
|
logger.debug(f"Creating engine for db_url: {db_url}")
|
|
142
208
|
|
|
143
|
-
|
|
209
|
+
# Configure connection args with Windows-specific settings
|
|
210
|
+
connect_args: dict[str, bool | float | None] = {"check_same_thread": False}
|
|
211
|
+
|
|
212
|
+
# Add Windows-specific parameters to improve reliability
|
|
213
|
+
if os.name == "nt": # Windows
|
|
214
|
+
connect_args.update(
|
|
215
|
+
{
|
|
216
|
+
"timeout": 30.0, # Increase timeout to 30 seconds for Windows
|
|
217
|
+
"isolation_level": None, # Use autocommit mode
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
# Use NullPool for Windows filesystem databases to avoid connection pooling issues
|
|
221
|
+
# Important: Do NOT use NullPool for in-memory databases as it will destroy the database
|
|
222
|
+
# between connections
|
|
223
|
+
if db_type == DatabaseType.FILESYSTEM:
|
|
224
|
+
_engine = create_async_engine(
|
|
225
|
+
db_url,
|
|
226
|
+
connect_args=connect_args,
|
|
227
|
+
poolclass=NullPool, # Disable connection pooling on Windows
|
|
228
|
+
echo=False,
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
# In-memory databases need connection pooling to maintain state
|
|
232
|
+
_engine = create_async_engine(db_url, connect_args=connect_args)
|
|
233
|
+
else:
|
|
234
|
+
_engine = create_async_engine(db_url, connect_args=connect_args)
|
|
235
|
+
|
|
236
|
+
# Enable WAL mode for better concurrency and reliability
|
|
237
|
+
# Note: WAL mode is not supported for in-memory databases
|
|
238
|
+
enable_wal = db_type != DatabaseType.MEMORY
|
|
239
|
+
|
|
240
|
+
@event.listens_for(_engine.sync_engine, "connect")
|
|
241
|
+
def enable_wal_mode(dbapi_conn, connection_record):
|
|
242
|
+
"""Enable WAL mode on each connection."""
|
|
243
|
+
_configure_sqlite_connection(dbapi_conn, enable_wal=enable_wal)
|
|
244
|
+
|
|
144
245
|
try:
|
|
145
246
|
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
|
|
146
247
|
|
basic_memory/deps.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from typing import Annotated
|
|
4
4
|
from loguru import logger
|
|
5
5
|
|
|
6
|
-
from fastapi import Depends, HTTPException, Path, status
|
|
6
|
+
from fastapi import Depends, HTTPException, Path, status, Request
|
|
7
7
|
from sqlalchemy.ext.asyncio import (
|
|
8
8
|
AsyncSession,
|
|
9
9
|
AsyncEngine,
|
|
@@ -33,6 +33,7 @@ from basic_memory.services.file_service import FileService
|
|
|
33
33
|
from basic_memory.services.link_resolver import LinkResolver
|
|
34
34
|
from basic_memory.services.search_service import SearchService
|
|
35
35
|
from basic_memory.sync import SyncService
|
|
36
|
+
from basic_memory.utils import generate_permalink
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
def get_app_config() -> BasicMemoryConfig: # pragma: no cover
|
|
@@ -61,8 +62,9 @@ async def get_project_config(
|
|
|
61
62
|
Raises:
|
|
62
63
|
HTTPException: If project is not found
|
|
63
64
|
"""
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
# Convert project name to permalink for lookup
|
|
66
|
+
project_permalink = generate_permalink(str(project))
|
|
67
|
+
project_obj = await project_repository.get_by_permalink(project_permalink)
|
|
66
68
|
if project_obj:
|
|
67
69
|
return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
|
|
68
70
|
|
|
@@ -78,9 +80,24 @@ ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # prag
|
|
|
78
80
|
|
|
79
81
|
|
|
80
82
|
async def get_engine_factory(
|
|
81
|
-
|
|
83
|
+
request: Request,
|
|
82
84
|
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
|
83
|
-
"""Get engine and session maker.
|
|
85
|
+
"""Get cached engine and session maker from app state.
|
|
86
|
+
|
|
87
|
+
For API requests, returns cached connections from app.state for optimal performance.
|
|
88
|
+
For non-API contexts (CLI), falls back to direct database connection.
|
|
89
|
+
"""
|
|
90
|
+
# Try to get cached connections from app state (API context)
|
|
91
|
+
if (
|
|
92
|
+
hasattr(request, "app")
|
|
93
|
+
and hasattr(request.app.state, "engine")
|
|
94
|
+
and hasattr(request.app.state, "session_maker")
|
|
95
|
+
):
|
|
96
|
+
return request.app.state.engine, request.app.state.session_maker
|
|
97
|
+
|
|
98
|
+
# Fallback for non-API contexts (CLI)
|
|
99
|
+
logger.debug("Using fallback database connection for non-API context")
|
|
100
|
+
app_config = get_app_config()
|
|
84
101
|
engine, session_maker = await db.get_or_create_db(app_config.database_path)
|
|
85
102
|
return engine, session_maker
|
|
86
103
|
|
|
@@ -132,9 +149,9 @@ async def get_project_id(
|
|
|
132
149
|
Raises:
|
|
133
150
|
HTTPException: If project is not found
|
|
134
151
|
"""
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
project_obj = await project_repository.get_by_permalink(
|
|
152
|
+
# Convert project name to permalink for lookup
|
|
153
|
+
project_permalink = generate_permalink(str(project))
|
|
154
|
+
project_obj = await project_repository.get_by_permalink(project_permalink)
|
|
138
155
|
if project_obj:
|
|
139
156
|
return project_obj.id
|
|
140
157
|
|
|
@@ -245,6 +262,7 @@ async def get_entity_service(
|
|
|
245
262
|
entity_parser: EntityParserDep,
|
|
246
263
|
file_service: FileServiceDep,
|
|
247
264
|
link_resolver: "LinkResolverDep",
|
|
265
|
+
app_config: AppConfigDep,
|
|
248
266
|
) -> EntityService:
|
|
249
267
|
"""Create EntityService with repository."""
|
|
250
268
|
return EntityService(
|
|
@@ -254,6 +272,7 @@ async def get_entity_service(
|
|
|
254
272
|
entity_parser=entity_parser,
|
|
255
273
|
file_service=file_service,
|
|
256
274
|
link_resolver=link_resolver,
|
|
275
|
+
app_config=app_config,
|
|
257
276
|
)
|
|
258
277
|
|
|
259
278
|
|
basic_memory/file_utils.py
CHANGED
|
@@ -240,40 +240,37 @@ async def update_frontmatter(path: FilePath, updates: Dict[str, Any]) -> str:
|
|
|
240
240
|
def dump_frontmatter(post: frontmatter.Post) -> str:
|
|
241
241
|
"""
|
|
242
242
|
Serialize frontmatter.Post to markdown with Obsidian-compatible YAML format.
|
|
243
|
-
|
|
243
|
+
|
|
244
244
|
This function ensures that tags are formatted as YAML lists instead of JSON arrays:
|
|
245
|
-
|
|
245
|
+
|
|
246
246
|
Good (Obsidian compatible):
|
|
247
247
|
---
|
|
248
248
|
tags:
|
|
249
249
|
- system
|
|
250
|
-
- overview
|
|
250
|
+
- overview
|
|
251
251
|
- reference
|
|
252
252
|
---
|
|
253
|
-
|
|
253
|
+
|
|
254
254
|
Bad (current behavior):
|
|
255
255
|
---
|
|
256
256
|
tags: ["system", "overview", "reference"]
|
|
257
257
|
---
|
|
258
|
-
|
|
258
|
+
|
|
259
259
|
Args:
|
|
260
260
|
post: frontmatter.Post object to serialize
|
|
261
|
-
|
|
261
|
+
|
|
262
262
|
Returns:
|
|
263
263
|
String containing markdown with properly formatted YAML frontmatter
|
|
264
|
-
"""
|
|
264
|
+
"""
|
|
265
265
|
if not post.metadata:
|
|
266
266
|
# No frontmatter, just return content
|
|
267
267
|
return post.content
|
|
268
|
-
|
|
268
|
+
|
|
269
269
|
# Serialize YAML with block style for lists
|
|
270
270
|
yaml_str = yaml.dump(
|
|
271
|
-
post.metadata,
|
|
272
|
-
sort_keys=False,
|
|
273
|
-
allow_unicode=True,
|
|
274
|
-
default_flow_style=False
|
|
271
|
+
post.metadata, sort_keys=False, allow_unicode=True, default_flow_style=False
|
|
275
272
|
)
|
|
276
|
-
|
|
273
|
+
|
|
277
274
|
# Construct the final markdown with frontmatter
|
|
278
275
|
if post.content:
|
|
279
276
|
return f"---\n{yaml_str}---\n\n{post.content}"
|
|
@@ -298,3 +295,30 @@ def sanitize_for_filename(text: str, replacement: str = "-") -> str:
|
|
|
298
295
|
|
|
299
296
|
return text.strip(replacement)
|
|
300
297
|
|
|
298
|
+
|
|
299
|
+
def sanitize_for_folder(folder: str) -> str:
|
|
300
|
+
"""
|
|
301
|
+
Sanitize folder path to be safe for use in file system paths.
|
|
302
|
+
Removes leading/trailing whitespace, compresses multiple slashes,
|
|
303
|
+
and removes special characters except for /, -, and _.
|
|
304
|
+
"""
|
|
305
|
+
if not folder:
|
|
306
|
+
return ""
|
|
307
|
+
|
|
308
|
+
sanitized = folder.strip()
|
|
309
|
+
|
|
310
|
+
if sanitized.startswith("./"):
|
|
311
|
+
sanitized = sanitized[2:]
|
|
312
|
+
|
|
313
|
+
# ensure no special characters (except for a few that are allowed)
|
|
314
|
+
sanitized = "".join(
|
|
315
|
+
c for c in sanitized if c.isalnum() or c in (".", " ", "-", "_", "\\", "/")
|
|
316
|
+
).rstrip()
|
|
317
|
+
|
|
318
|
+
# compress multiple, repeated instances of path separators
|
|
319
|
+
sanitized = re.sub(r"[\\/]+", "/", sanitized)
|
|
320
|
+
|
|
321
|
+
# trim any leading/trailing path separators
|
|
322
|
+
sanitized = sanitized.strip("\\/")
|
|
323
|
+
|
|
324
|
+
return sanitized
|