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/db.py
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
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
|
|
5
6
|
from typing import AsyncGenerator, Optional
|
|
6
7
|
|
|
8
|
+
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
|
9
|
+
from alembic import command
|
|
10
|
+
from alembic.config import Config
|
|
11
|
+
|
|
7
12
|
from loguru import logger
|
|
8
|
-
from sqlalchemy import text
|
|
13
|
+
from sqlalchemy import text, event
|
|
9
14
|
from sqlalchemy.ext.asyncio import (
|
|
10
15
|
create_async_engine,
|
|
11
16
|
async_sessionmaker,
|
|
@@ -13,13 +18,14 @@ from sqlalchemy.ext.asyncio import (
|
|
|
13
18
|
AsyncEngine,
|
|
14
19
|
async_scoped_session,
|
|
15
20
|
)
|
|
21
|
+
from sqlalchemy.pool import NullPool
|
|
16
22
|
|
|
17
|
-
from basic_memory.
|
|
18
|
-
from basic_memory.models.search import CREATE_SEARCH_INDEX
|
|
23
|
+
from basic_memory.repository.search_repository import SearchRepository
|
|
19
24
|
|
|
20
25
|
# Module level state
|
|
21
26
|
_engine: Optional[AsyncEngine] = None
|
|
22
27
|
_session_maker: Optional[async_sessionmaker[AsyncSession]] = None
|
|
28
|
+
_migrations_completed: bool = False
|
|
23
29
|
|
|
24
30
|
|
|
25
31
|
class DatabaseType(Enum):
|
|
@@ -35,7 +41,7 @@ class DatabaseType(Enum):
|
|
|
35
41
|
logger.info("Using in-memory SQLite database")
|
|
36
42
|
return "sqlite+aiosqlite://"
|
|
37
43
|
|
|
38
|
-
return f"sqlite+aiosqlite:///{db_path}"
|
|
44
|
+
return f"sqlite+aiosqlite:///{db_path}" # pragma: no cover
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
def get_scoped_session_factory(
|
|
@@ -69,60 +75,125 @@ async def scoped_session(
|
|
|
69
75
|
await factory.remove()
|
|
70
76
|
|
|
71
77
|
|
|
72
|
-
|
|
73
|
-
"""
|
|
74
|
-
if _session_maker is None: # pragma: no cover
|
|
75
|
-
raise RuntimeError("Database session maker not initialized")
|
|
78
|
+
def _configure_sqlite_connection(dbapi_conn, enable_wal: bool = True) -> None:
|
|
79
|
+
"""Configure SQLite connection with WAL mode and optimizations.
|
|
76
80
|
|
|
77
|
-
|
|
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()
|
|
78
104
|
|
|
79
|
-
async with scoped_session(_session_maker) as session:
|
|
80
|
-
await session.execute(text("PRAGMA foreign_keys=ON"))
|
|
81
|
-
conn = await session.connection()
|
|
82
|
-
await conn.run_sync(Base.metadata.create_all)
|
|
83
105
|
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
def _create_engine_and_session(
|
|
107
|
+
db_path: Path, db_type: DatabaseType = DatabaseType.FILESYSTEM
|
|
108
|
+
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
|
109
|
+
"""Internal helper to create engine and session maker."""
|
|
110
|
+
db_url = DatabaseType.get_db_url(db_path, db_type)
|
|
111
|
+
logger.debug(f"Creating engine for db_url: {db_url}")
|
|
86
112
|
|
|
87
|
-
|
|
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
|
+
|
|
149
|
+
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
150
|
+
return engine, session_maker
|
|
88
151
|
|
|
89
152
|
|
|
90
153
|
async def get_or_create_db(
|
|
91
154
|
db_path: Path,
|
|
92
155
|
db_type: DatabaseType = DatabaseType.FILESYSTEM,
|
|
156
|
+
ensure_migrations: bool = True,
|
|
93
157
|
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
|
94
158
|
"""Get or create database engine and session maker."""
|
|
95
159
|
global _engine, _session_maker
|
|
96
160
|
|
|
97
161
|
if _engine is None:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
162
|
+
_engine, _session_maker = _create_engine_and_session(db_path, db_type)
|
|
163
|
+
|
|
164
|
+
# Run migrations automatically unless explicitly disabled
|
|
165
|
+
if ensure_migrations:
|
|
166
|
+
app_config = ConfigManager().config
|
|
167
|
+
await run_migrations(app_config, db_type)
|
|
102
168
|
|
|
103
|
-
|
|
104
|
-
|
|
169
|
+
# These checks should never fail since we just created the engine and session maker
|
|
170
|
+
# if they were None, but we'll check anyway for the type checker
|
|
171
|
+
if _engine is None:
|
|
172
|
+
logger.error("Failed to create database engine", db_path=str(db_path))
|
|
173
|
+
raise RuntimeError("Database engine initialization failed")
|
|
174
|
+
|
|
175
|
+
if _session_maker is None:
|
|
176
|
+
logger.error("Failed to create session maker", db_path=str(db_path))
|
|
177
|
+
raise RuntimeError("Session maker initialization failed")
|
|
105
178
|
|
|
106
|
-
assert _engine is not None # for type checker
|
|
107
|
-
assert _session_maker is not None # for type checker
|
|
108
179
|
return _engine, _session_maker
|
|
109
180
|
|
|
110
181
|
|
|
111
182
|
async def shutdown_db() -> None: # pragma: no cover
|
|
112
183
|
"""Clean up database connections."""
|
|
113
|
-
global _engine, _session_maker
|
|
184
|
+
global _engine, _session_maker, _migrations_completed
|
|
114
185
|
|
|
115
186
|
if _engine:
|
|
116
187
|
await _engine.dispose()
|
|
117
188
|
_engine = None
|
|
118
189
|
_session_maker = None
|
|
190
|
+
_migrations_completed = False
|
|
119
191
|
|
|
120
192
|
|
|
121
193
|
@asynccontextmanager
|
|
122
194
|
async def engine_session_factory(
|
|
123
195
|
db_path: Path,
|
|
124
196
|
db_type: DatabaseType = DatabaseType.MEMORY,
|
|
125
|
-
init: bool = True,
|
|
126
197
|
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
|
|
127
198
|
"""Create engine and session factory.
|
|
128
199
|
|
|
@@ -130,23 +201,112 @@ async def engine_session_factory(
|
|
|
130
201
|
for each test. For production use, use get_or_create_db() instead.
|
|
131
202
|
"""
|
|
132
203
|
|
|
133
|
-
global _engine, _session_maker
|
|
204
|
+
global _engine, _session_maker, _migrations_completed
|
|
134
205
|
|
|
135
206
|
db_url = DatabaseType.get_db_url(db_path, db_type)
|
|
136
207
|
logger.debug(f"Creating engine for db_url: {db_url}")
|
|
137
208
|
|
|
138
|
-
|
|
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
|
+
|
|
139
245
|
try:
|
|
140
246
|
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
|
|
141
247
|
|
|
142
|
-
|
|
143
|
-
|
|
248
|
+
# Verify that engine and session maker are initialized
|
|
249
|
+
if _engine is None: # pragma: no cover
|
|
250
|
+
logger.error("Database engine is None in engine_session_factory")
|
|
251
|
+
raise RuntimeError("Database engine initialization failed")
|
|
252
|
+
|
|
253
|
+
if _session_maker is None: # pragma: no cover
|
|
254
|
+
logger.error("Session maker is None in engine_session_factory")
|
|
255
|
+
raise RuntimeError("Session maker initialization failed")
|
|
144
256
|
|
|
145
|
-
assert _engine is not None # for type checker
|
|
146
|
-
assert _session_maker is not None # for type checker
|
|
147
257
|
yield _engine, _session_maker
|
|
148
258
|
finally:
|
|
149
259
|
if _engine:
|
|
150
260
|
await _engine.dispose()
|
|
151
261
|
_engine = None
|
|
152
262
|
_session_maker = None
|
|
263
|
+
_migrations_completed = False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def run_migrations(
|
|
267
|
+
app_config: BasicMemoryConfig, database_type=DatabaseType.FILESYSTEM, force: bool = False
|
|
268
|
+
): # pragma: no cover
|
|
269
|
+
"""Run any pending alembic migrations."""
|
|
270
|
+
global _migrations_completed
|
|
271
|
+
|
|
272
|
+
# Skip if migrations already completed unless forced
|
|
273
|
+
if _migrations_completed and not force:
|
|
274
|
+
logger.debug("Migrations already completed in this session, skipping")
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
logger.info("Running database migrations...")
|
|
278
|
+
try:
|
|
279
|
+
# Get the absolute path to the alembic directory relative to this file
|
|
280
|
+
alembic_dir = Path(__file__).parent / "alembic"
|
|
281
|
+
config = Config()
|
|
282
|
+
|
|
283
|
+
# Set required Alembic config options programmatically
|
|
284
|
+
config.set_main_option("script_location", str(alembic_dir))
|
|
285
|
+
config.set_main_option(
|
|
286
|
+
"file_template",
|
|
287
|
+
"%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s",
|
|
288
|
+
)
|
|
289
|
+
config.set_main_option("timezone", "UTC")
|
|
290
|
+
config.set_main_option("revision_environment", "false")
|
|
291
|
+
config.set_main_option(
|
|
292
|
+
"sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
command.upgrade(config, "head")
|
|
296
|
+
logger.info("Migrations completed successfully")
|
|
297
|
+
|
|
298
|
+
# Get session maker - ensure we don't trigger recursive migration calls
|
|
299
|
+
if _session_maker is None:
|
|
300
|
+
_, session_maker = _create_engine_and_session(app_config.database_path, database_type)
|
|
301
|
+
else:
|
|
302
|
+
session_maker = _session_maker
|
|
303
|
+
|
|
304
|
+
# initialize the search Index schema
|
|
305
|
+
# the project_id is not used for init_search_index, so we pass a dummy value
|
|
306
|
+
await SearchRepository(session_maker, 1).init_search_index()
|
|
307
|
+
|
|
308
|
+
# Mark migrations as completed
|
|
309
|
+
_migrations_completed = True
|
|
310
|
+
except Exception as e: # pragma: no cover
|
|
311
|
+
logger.error(f"Error running migrations: {e}")
|
|
312
|
+
raise
|
basic_memory/deps.py
CHANGED
|
@@ -1,49 +1,105 @@
|
|
|
1
1
|
"""Dependency injection functions for basic-memory services."""
|
|
2
2
|
|
|
3
3
|
from typing import Annotated
|
|
4
|
+
from loguru import logger
|
|
4
5
|
|
|
5
|
-
from fastapi import Depends
|
|
6
|
+
from fastapi import Depends, HTTPException, Path, status, Request
|
|
6
7
|
from sqlalchemy.ext.asyncio import (
|
|
7
8
|
AsyncSession,
|
|
8
9
|
AsyncEngine,
|
|
9
10
|
async_sessionmaker,
|
|
10
11
|
)
|
|
12
|
+
import pathlib
|
|
11
13
|
|
|
12
14
|
from basic_memory import db
|
|
13
|
-
from basic_memory.config import ProjectConfig,
|
|
15
|
+
from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager
|
|
16
|
+
from basic_memory.importers import (
|
|
17
|
+
ChatGPTImporter,
|
|
18
|
+
ClaudeConversationsImporter,
|
|
19
|
+
ClaudeProjectsImporter,
|
|
20
|
+
MemoryJsonImporter,
|
|
21
|
+
)
|
|
14
22
|
from basic_memory.markdown import EntityParser
|
|
15
23
|
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
|
16
24
|
from basic_memory.repository.entity_repository import EntityRepository
|
|
17
25
|
from basic_memory.repository.observation_repository import ObservationRepository
|
|
26
|
+
from basic_memory.repository.project_repository import ProjectRepository
|
|
18
27
|
from basic_memory.repository.relation_repository import RelationRepository
|
|
19
28
|
from basic_memory.repository.search_repository import SearchRepository
|
|
20
|
-
from basic_memory.services import
|
|
21
|
-
EntityService,
|
|
22
|
-
)
|
|
29
|
+
from basic_memory.services import EntityService, ProjectService
|
|
23
30
|
from basic_memory.services.context_service import ContextService
|
|
31
|
+
from basic_memory.services.directory_service import DirectoryService
|
|
24
32
|
from basic_memory.services.file_service import FileService
|
|
25
33
|
from basic_memory.services.link_resolver import LinkResolver
|
|
26
34
|
from basic_memory.services.search_service import SearchService
|
|
35
|
+
from basic_memory.sync import SyncService
|
|
36
|
+
from basic_memory.utils import generate_permalink
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_app_config() -> BasicMemoryConfig: # pragma: no cover
|
|
40
|
+
app_config = ConfigManager().config
|
|
41
|
+
return app_config
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
AppConfigDep = Annotated[BasicMemoryConfig, Depends(get_app_config)] # pragma: no cover
|
|
27
45
|
|
|
28
46
|
|
|
29
47
|
## project
|
|
30
48
|
|
|
31
49
|
|
|
32
|
-
def get_project_config(
|
|
33
|
-
|
|
50
|
+
async def get_project_config(
|
|
51
|
+
project: "ProjectPathDep", project_repository: "ProjectRepositoryDep"
|
|
52
|
+
) -> ProjectConfig: # pragma: no cover
|
|
53
|
+
"""Get the current project referenced from request state.
|
|
34
54
|
|
|
55
|
+
Args:
|
|
56
|
+
request: The current request object
|
|
57
|
+
project_repository: Repository for project operations
|
|
35
58
|
|
|
36
|
-
|
|
59
|
+
Returns:
|
|
60
|
+
The resolved project config
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
HTTPException: If project is not found
|
|
64
|
+
"""
|
|
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)
|
|
68
|
+
if project_obj:
|
|
69
|
+
return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
|
|
37
70
|
|
|
71
|
+
# Not found
|
|
72
|
+
raise HTTPException( # pragma: no cover
|
|
73
|
+
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover
|
|
38
78
|
|
|
39
79
|
## sqlalchemy
|
|
40
80
|
|
|
41
81
|
|
|
42
82
|
async def get_engine_factory(
|
|
43
|
-
|
|
83
|
+
request: Request,
|
|
44
84
|
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
|
45
|
-
"""Get engine and session maker.
|
|
46
|
-
|
|
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()
|
|
101
|
+
engine, session_maker = await db.get_or_create_db(app_config.database_path)
|
|
102
|
+
return engine, session_maker
|
|
47
103
|
|
|
48
104
|
|
|
49
105
|
EngineFactoryDep = Annotated[
|
|
@@ -63,11 +119,70 @@ SessionMakerDep = Annotated[async_sessionmaker, Depends(get_session_maker)]
|
|
|
63
119
|
## repositories
|
|
64
120
|
|
|
65
121
|
|
|
122
|
+
async def get_project_repository(
|
|
123
|
+
session_maker: SessionMakerDep,
|
|
124
|
+
) -> ProjectRepository:
|
|
125
|
+
"""Get the project repository."""
|
|
126
|
+
return ProjectRepository(session_maker)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
ProjectRepositoryDep = Annotated[ProjectRepository, Depends(get_project_repository)]
|
|
130
|
+
ProjectPathDep = Annotated[str, Path()] # Use Path dependency to extract from URL
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def get_project_id(
|
|
134
|
+
project_repository: ProjectRepositoryDep,
|
|
135
|
+
project: ProjectPathDep,
|
|
136
|
+
) -> int:
|
|
137
|
+
"""Get the current project ID from request state.
|
|
138
|
+
|
|
139
|
+
When using sub-applications with /{project} mounting, the project value
|
|
140
|
+
is stored in request.state by middleware.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
request: The current request object
|
|
144
|
+
project_repository: Repository for project operations
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The resolved project ID
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
HTTPException: If project is not found
|
|
151
|
+
"""
|
|
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)
|
|
155
|
+
if project_obj:
|
|
156
|
+
return project_obj.id
|
|
157
|
+
|
|
158
|
+
# Try by name if permalink lookup fails
|
|
159
|
+
project_obj = await project_repository.get_by_name(str(project)) # pragma: no cover
|
|
160
|
+
if project_obj: # pragma: no cover
|
|
161
|
+
return project_obj.id
|
|
162
|
+
|
|
163
|
+
# Not found
|
|
164
|
+
raise HTTPException( # pragma: no cover
|
|
165
|
+
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
"""
|
|
170
|
+
The project_id dependency is used in the following:
|
|
171
|
+
- EntityRepository
|
|
172
|
+
- ObservationRepository
|
|
173
|
+
- RelationRepository
|
|
174
|
+
- SearchRepository
|
|
175
|
+
- ProjectInfoRepository
|
|
176
|
+
"""
|
|
177
|
+
ProjectIdDep = Annotated[int, Depends(get_project_id)]
|
|
178
|
+
|
|
179
|
+
|
|
66
180
|
async def get_entity_repository(
|
|
67
181
|
session_maker: SessionMakerDep,
|
|
182
|
+
project_id: ProjectIdDep,
|
|
68
183
|
) -> EntityRepository:
|
|
69
|
-
"""Create an EntityRepository instance."""
|
|
70
|
-
return EntityRepository(session_maker)
|
|
184
|
+
"""Create an EntityRepository instance for the current project."""
|
|
185
|
+
return EntityRepository(session_maker, project_id=project_id)
|
|
71
186
|
|
|
72
187
|
|
|
73
188
|
EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)]
|
|
@@ -75,9 +190,10 @@ EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)
|
|
|
75
190
|
|
|
76
191
|
async def get_observation_repository(
|
|
77
192
|
session_maker: SessionMakerDep,
|
|
193
|
+
project_id: ProjectIdDep,
|
|
78
194
|
) -> ObservationRepository:
|
|
79
|
-
"""Create an ObservationRepository instance."""
|
|
80
|
-
return ObservationRepository(session_maker)
|
|
195
|
+
"""Create an ObservationRepository instance for the current project."""
|
|
196
|
+
return ObservationRepository(session_maker, project_id=project_id)
|
|
81
197
|
|
|
82
198
|
|
|
83
199
|
ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)]
|
|
@@ -85,9 +201,10 @@ ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observat
|
|
|
85
201
|
|
|
86
202
|
async def get_relation_repository(
|
|
87
203
|
session_maker: SessionMakerDep,
|
|
204
|
+
project_id: ProjectIdDep,
|
|
88
205
|
) -> RelationRepository:
|
|
89
|
-
"""Create a RelationRepository instance."""
|
|
90
|
-
return RelationRepository(session_maker)
|
|
206
|
+
"""Create a RelationRepository instance for the current project."""
|
|
207
|
+
return RelationRepository(session_maker, project_id=project_id)
|
|
91
208
|
|
|
92
209
|
|
|
93
210
|
RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)]
|
|
@@ -95,14 +212,18 @@ RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repos
|
|
|
95
212
|
|
|
96
213
|
async def get_search_repository(
|
|
97
214
|
session_maker: SessionMakerDep,
|
|
215
|
+
project_id: ProjectIdDep,
|
|
98
216
|
) -> SearchRepository:
|
|
99
|
-
"""Create a SearchRepository instance."""
|
|
100
|
-
return SearchRepository(session_maker)
|
|
217
|
+
"""Create a SearchRepository instance for the current project."""
|
|
218
|
+
return SearchRepository(session_maker, project_id=project_id)
|
|
101
219
|
|
|
102
220
|
|
|
103
221
|
SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
|
|
104
222
|
|
|
105
223
|
|
|
224
|
+
# ProjectInfoRepository is deprecated and will be removed in a future version.
|
|
225
|
+
# Use ProjectRepository instead, which has the same functionality plus more project-specific operations.
|
|
226
|
+
|
|
106
227
|
## services
|
|
107
228
|
|
|
108
229
|
|
|
@@ -123,7 +244,12 @@ MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_process
|
|
|
123
244
|
async def get_file_service(
|
|
124
245
|
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
|
125
246
|
) -> FileService:
|
|
126
|
-
|
|
247
|
+
logger.debug(
|
|
248
|
+
f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}"
|
|
249
|
+
)
|
|
250
|
+
file_service = FileService(project_config.home, markdown_processor)
|
|
251
|
+
logger.debug(f"Created FileService for project: {file_service} ")
|
|
252
|
+
return file_service
|
|
127
253
|
|
|
128
254
|
|
|
129
255
|
FileServiceDep = Annotated[FileService, Depends(get_file_service)]
|
|
@@ -136,6 +262,7 @@ async def get_entity_service(
|
|
|
136
262
|
entity_parser: EntityParserDep,
|
|
137
263
|
file_service: FileServiceDep,
|
|
138
264
|
link_resolver: "LinkResolverDep",
|
|
265
|
+
app_config: AppConfigDep,
|
|
139
266
|
) -> EntityService:
|
|
140
267
|
"""Create EntityService with repository."""
|
|
141
268
|
return EntityService(
|
|
@@ -145,6 +272,7 @@ async def get_entity_service(
|
|
|
145
272
|
entity_parser=entity_parser,
|
|
146
273
|
file_service=file_service,
|
|
147
274
|
link_resolver=link_resolver,
|
|
275
|
+
app_config=app_config,
|
|
148
276
|
)
|
|
149
277
|
|
|
150
278
|
|
|
@@ -173,9 +301,111 @@ LinkResolverDep = Annotated[LinkResolver, Depends(get_link_resolver)]
|
|
|
173
301
|
|
|
174
302
|
|
|
175
303
|
async def get_context_service(
|
|
176
|
-
search_repository: SearchRepositoryDep,
|
|
304
|
+
search_repository: SearchRepositoryDep,
|
|
305
|
+
entity_repository: EntityRepositoryDep,
|
|
306
|
+
observation_repository: ObservationRepositoryDep,
|
|
177
307
|
) -> ContextService:
|
|
178
|
-
return ContextService(
|
|
308
|
+
return ContextService(
|
|
309
|
+
search_repository=search_repository,
|
|
310
|
+
entity_repository=entity_repository,
|
|
311
|
+
observation_repository=observation_repository,
|
|
312
|
+
)
|
|
179
313
|
|
|
180
314
|
|
|
181
315
|
ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def get_sync_service(
|
|
319
|
+
app_config: AppConfigDep,
|
|
320
|
+
entity_service: EntityServiceDep,
|
|
321
|
+
entity_parser: EntityParserDep,
|
|
322
|
+
entity_repository: EntityRepositoryDep,
|
|
323
|
+
relation_repository: RelationRepositoryDep,
|
|
324
|
+
project_repository: ProjectRepositoryDep,
|
|
325
|
+
search_service: SearchServiceDep,
|
|
326
|
+
file_service: FileServiceDep,
|
|
327
|
+
) -> SyncService: # pragma: no cover
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
:rtype: object
|
|
331
|
+
"""
|
|
332
|
+
return SyncService(
|
|
333
|
+
app_config=app_config,
|
|
334
|
+
entity_service=entity_service,
|
|
335
|
+
entity_parser=entity_parser,
|
|
336
|
+
entity_repository=entity_repository,
|
|
337
|
+
relation_repository=relation_repository,
|
|
338
|
+
project_repository=project_repository,
|
|
339
|
+
search_service=search_service,
|
|
340
|
+
file_service=file_service,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def get_project_service(
|
|
348
|
+
project_repository: ProjectRepositoryDep,
|
|
349
|
+
) -> ProjectService:
|
|
350
|
+
"""Create ProjectService with repository."""
|
|
351
|
+
return ProjectService(repository=project_repository)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
ProjectServiceDep = Annotated[ProjectService, Depends(get_project_service)]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
async def get_directory_service(
|
|
358
|
+
entity_repository: EntityRepositoryDep,
|
|
359
|
+
) -> DirectoryService:
|
|
360
|
+
"""Create DirectoryService with dependencies."""
|
|
361
|
+
return DirectoryService(
|
|
362
|
+
entity_repository=entity_repository,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
DirectoryServiceDep = Annotated[DirectoryService, Depends(get_directory_service)]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# Import
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def get_chatgpt_importer(
|
|
373
|
+
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
|
374
|
+
) -> ChatGPTImporter:
|
|
375
|
+
"""Create ChatGPTImporter with dependencies."""
|
|
376
|
+
return ChatGPTImporter(project_config.home, markdown_processor)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def get_claude_conversations_importer(
|
|
383
|
+
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
|
384
|
+
) -> ClaudeConversationsImporter:
|
|
385
|
+
"""Create ChatGPTImporter with dependencies."""
|
|
386
|
+
return ClaudeConversationsImporter(project_config.home, markdown_processor)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
ClaudeConversationsImporterDep = Annotated[
|
|
390
|
+
ClaudeConversationsImporter, Depends(get_claude_conversations_importer)
|
|
391
|
+
]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def get_claude_projects_importer(
|
|
395
|
+
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
|
396
|
+
) -> ClaudeProjectsImporter:
|
|
397
|
+
"""Create ChatGPTImporter with dependencies."""
|
|
398
|
+
return ClaudeProjectsImporter(project_config.home, markdown_processor)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
async def get_memory_json_importer(
|
|
405
|
+
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
|
406
|
+
) -> MemoryJsonImporter:
|
|
407
|
+
"""Create ChatGPTImporter with dependencies."""
|
|
408
|
+
return MemoryJsonImporter(project_config.home, markdown_processor)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
|