basic-memory 0.12.2__py3-none-any.whl → 0.13.0__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 +2 -1
- basic_memory/alembic/env.py +1 -1
- 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/cc7172b46608_update_search_index_schema.py +0 -6
- basic_memory/api/app.py +43 -13
- basic_memory/api/routers/__init__.py +4 -2
- basic_memory/api/routers/directory_router.py +63 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +139 -37
- basic_memory/api/routers/management_router.py +78 -0
- basic_memory/api/routers/memory_router.py +6 -62
- basic_memory/api/routers/project_router.py +234 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/search_router.py +3 -21
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +20 -21
- basic_memory/cli/commands/__init__.py +2 -1
- basic_memory/cli/commands/auth.py +136 -0
- basic_memory/cli/commands/db.py +3 -3
- basic_memory/cli/commands/import_chatgpt.py +31 -207
- basic_memory/cli/commands/import_claude_conversations.py +16 -142
- basic_memory/cli/commands/import_claude_projects.py +33 -143
- basic_memory/cli/commands/import_memory_json.py +26 -83
- basic_memory/cli/commands/mcp.py +71 -18
- basic_memory/cli/commands/project.py +102 -70
- basic_memory/cli/commands/status.py +19 -9
- basic_memory/cli/commands/sync.py +44 -58
- basic_memory/cli/commands/tool.py +6 -6
- basic_memory/cli/main.py +1 -5
- basic_memory/config.py +143 -87
- basic_memory/db.py +6 -4
- basic_memory/deps.py +227 -30
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +222 -0
- basic_memory/importers/claude_conversations_importer.py +172 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +93 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +5 -2
- basic_memory/mcp/auth_provider.py +270 -0
- basic_memory/mcp/external_auth_provider.py +321 -0
- basic_memory/mcp/project_session.py +103 -0
- basic_memory/mcp/prompts/__init__.py +2 -0
- basic_memory/mcp/prompts/continue_conversation.py +18 -68
- basic_memory/mcp/prompts/recent_activity.py +20 -4
- basic_memory/mcp/prompts/search.py +14 -140
- basic_memory/mcp/prompts/sync_status.py +116 -0
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/{tools → resources}/project_info.py +6 -2
- basic_memory/mcp/server.py +86 -13
- basic_memory/mcp/supabase_auth_provider.py +463 -0
- basic_memory/mcp/tools/__init__.py +24 -0
- basic_memory/mcp/tools/build_context.py +43 -8
- basic_memory/mcp/tools/canvas.py +17 -3
- basic_memory/mcp/tools/delete_note.py +168 -5
- basic_memory/mcp/tools/edit_note.py +303 -0
- basic_memory/mcp/tools/list_directory.py +154 -0
- basic_memory/mcp/tools/move_note.py +299 -0
- basic_memory/mcp/tools/project_management.py +332 -0
- basic_memory/mcp/tools/read_content.py +15 -6
- basic_memory/mcp/tools/read_note.py +28 -9
- basic_memory/mcp/tools/recent_activity.py +47 -16
- basic_memory/mcp/tools/search.py +189 -8
- basic_memory/mcp/tools/sync_status.py +254 -0
- basic_memory/mcp/tools/utils.py +184 -12
- basic_memory/mcp/tools/view_note.py +66 -0
- basic_memory/mcp/tools/write_note.py +24 -17
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +16 -4
- basic_memory/models/project.py +78 -0
- basic_memory/models/search.py +8 -5
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +8 -3
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +3 -2
- basic_memory/repository/project_repository.py +85 -0
- basic_memory/repository/relation_repository.py +8 -2
- basic_memory/repository/repository.py +107 -15
- basic_memory/repository/search_repository.py +192 -54
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +33 -5
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +34 -0
- basic_memory/schemas/memory.py +84 -13
- basic_memory/schemas/project_info.py +112 -2
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/search.py +1 -1
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +208 -95
- basic_memory/services/directory_service.py +167 -0
- basic_memory/services/entity_service.py +399 -6
- basic_memory/services/exceptions.py +6 -0
- basic_memory/services/file_service.py +14 -15
- basic_memory/services/initialization.py +170 -66
- basic_memory/services/link_resolver.py +35 -12
- basic_memory/services/migration_service.py +168 -0
- basic_memory/services/project_service.py +671 -0
- basic_memory/services/search_service.py +77 -2
- basic_memory/services/sync_status_service.py +181 -0
- basic_memory/sync/background_sync.py +25 -0
- basic_memory/sync/sync_service.py +102 -21
- basic_memory/sync/watch_service.py +63 -39
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +67 -17
- {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/METADATA +26 -4
- basic_memory-0.13.0.dist-info/RECORD +138 -0
- basic_memory/api/routers/project_info_router.py +0 -274
- basic_memory/mcp/main.py +0 -24
- basic_memory-0.12.2.dist-info/RECORD +0 -100
- {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Base repository implementation."""
|
|
2
2
|
|
|
3
|
-
from typing import Type, Optional, Any, Sequence, TypeVar, List
|
|
3
|
+
from typing import Type, Optional, Any, Sequence, TypeVar, List, Dict
|
|
4
4
|
|
|
5
5
|
from loguru import logger
|
|
6
6
|
from sqlalchemy import (
|
|
@@ -27,13 +27,30 @@ T = TypeVar("T", bound=Base)
|
|
|
27
27
|
class Repository[T: Base]:
|
|
28
28
|
"""Base repository implementation with generic CRUD operations."""
|
|
29
29
|
|
|
30
|
-
def __init__(
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
session_maker: async_sessionmaker[AsyncSession],
|
|
33
|
+
Model: Type[T],
|
|
34
|
+
project_id: Optional[int] = None,
|
|
35
|
+
):
|
|
31
36
|
self.session_maker = session_maker
|
|
37
|
+
self.project_id = project_id
|
|
32
38
|
if Model:
|
|
33
39
|
self.Model = Model
|
|
34
40
|
self.mapper = inspect(self.Model).mapper
|
|
35
41
|
self.primary_key: Column[Any] = self.mapper.primary_key[0]
|
|
36
42
|
self.valid_columns = [column.key for column in self.mapper.columns]
|
|
43
|
+
# Check if this model has a project_id column
|
|
44
|
+
self.has_project_id = "project_id" in self.valid_columns
|
|
45
|
+
|
|
46
|
+
def _set_project_id_if_needed(self, model: T) -> None:
|
|
47
|
+
"""Set project_id on model if needed and available."""
|
|
48
|
+
if (
|
|
49
|
+
self.has_project_id
|
|
50
|
+
and self.project_id is not None
|
|
51
|
+
and getattr(model, "project_id", None) is None
|
|
52
|
+
):
|
|
53
|
+
setattr(model, "project_id", self.project_id)
|
|
37
54
|
|
|
38
55
|
def get_model_data(self, entity_data):
|
|
39
56
|
model_data = {
|
|
@@ -41,6 +58,19 @@ class Repository[T: Base]:
|
|
|
41
58
|
}
|
|
42
59
|
return model_data
|
|
43
60
|
|
|
61
|
+
def _add_project_filter(self, query: Select) -> Select:
|
|
62
|
+
"""Add project_id filter to query if applicable.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
query: The SQLAlchemy query to modify
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Updated query with project filter if applicable
|
|
69
|
+
"""
|
|
70
|
+
if self.has_project_id and self.project_id is not None:
|
|
71
|
+
query = query.filter(getattr(self.Model, "project_id") == self.project_id)
|
|
72
|
+
return query
|
|
73
|
+
|
|
44
74
|
async def select_by_id(self, session: AsyncSession, entity_id: int) -> Optional[T]:
|
|
45
75
|
"""Select an entity by ID using an existing session."""
|
|
46
76
|
query = (
|
|
@@ -48,6 +78,9 @@ class Repository[T: Base]:
|
|
|
48
78
|
.filter(self.primary_key == entity_id)
|
|
49
79
|
.options(*self.get_load_options())
|
|
50
80
|
)
|
|
81
|
+
# Add project filter if applicable
|
|
82
|
+
query = self._add_project_filter(query)
|
|
83
|
+
|
|
51
84
|
result = await session.execute(query)
|
|
52
85
|
return result.scalars().one_or_none()
|
|
53
86
|
|
|
@@ -56,6 +89,9 @@ class Repository[T: Base]:
|
|
|
56
89
|
query = (
|
|
57
90
|
select(self.Model).where(self.primary_key.in_(ids)).options(*self.get_load_options())
|
|
58
91
|
)
|
|
92
|
+
# Add project filter if applicable
|
|
93
|
+
query = self._add_project_filter(query)
|
|
94
|
+
|
|
59
95
|
result = await session.execute(query)
|
|
60
96
|
return result.scalars().all()
|
|
61
97
|
|
|
@@ -66,6 +102,9 @@ class Repository[T: Base]:
|
|
|
66
102
|
:return: the added model instance
|
|
67
103
|
"""
|
|
68
104
|
async with db.scoped_session(self.session_maker) as session:
|
|
105
|
+
# Set project_id if applicable and not already set
|
|
106
|
+
self._set_project_id_if_needed(model)
|
|
107
|
+
|
|
69
108
|
session.add(model)
|
|
70
109
|
await session.flush()
|
|
71
110
|
|
|
@@ -89,6 +128,10 @@ class Repository[T: Base]:
|
|
|
89
128
|
:return: the added models instances
|
|
90
129
|
"""
|
|
91
130
|
async with db.scoped_session(self.session_maker) as session:
|
|
131
|
+
# set the project id if not present in models
|
|
132
|
+
for model in models:
|
|
133
|
+
self._set_project_id_if_needed(model)
|
|
134
|
+
|
|
92
135
|
session.add_all(models)
|
|
93
136
|
await session.flush()
|
|
94
137
|
|
|
@@ -104,7 +147,10 @@ class Repository[T: Base]:
|
|
|
104
147
|
"""
|
|
105
148
|
if not entities:
|
|
106
149
|
entities = (self.Model,)
|
|
107
|
-
|
|
150
|
+
query = select(*entities)
|
|
151
|
+
|
|
152
|
+
# Add project filter if applicable
|
|
153
|
+
return self._add_project_filter(query)
|
|
108
154
|
|
|
109
155
|
async def find_all(self, skip: int = 0, limit: Optional[int] = None) -> Sequence[T]:
|
|
110
156
|
"""Fetch records from the database with pagination."""
|
|
@@ -112,6 +158,9 @@ class Repository[T: Base]:
|
|
|
112
158
|
|
|
113
159
|
async with db.scoped_session(self.session_maker) as session:
|
|
114
160
|
query = select(self.Model).offset(skip).options(*self.get_load_options())
|
|
161
|
+
# Add project filter if applicable
|
|
162
|
+
query = self._add_project_filter(query)
|
|
163
|
+
|
|
115
164
|
if limit:
|
|
116
165
|
query = query.limit(limit)
|
|
117
166
|
|
|
@@ -143,9 +192,9 @@ class Repository[T: Base]:
|
|
|
143
192
|
entity = result.scalars().one_or_none()
|
|
144
193
|
|
|
145
194
|
if entity:
|
|
146
|
-
logger.
|
|
195
|
+
logger.trace(f"Found {self.Model.__name__}: {getattr(entity, 'id', None)}")
|
|
147
196
|
else:
|
|
148
|
-
logger.
|
|
197
|
+
logger.trace(f"No {self.Model.__name__} found")
|
|
149
198
|
return entity
|
|
150
199
|
|
|
151
200
|
async def create(self, data: dict) -> T:
|
|
@@ -154,6 +203,15 @@ class Repository[T: Base]:
|
|
|
154
203
|
async with db.scoped_session(self.session_maker) as session:
|
|
155
204
|
# Only include valid columns that are provided in entity_data
|
|
156
205
|
model_data = self.get_model_data(data)
|
|
206
|
+
|
|
207
|
+
# Add project_id if applicable and not already provided
|
|
208
|
+
if (
|
|
209
|
+
self.has_project_id
|
|
210
|
+
and self.project_id is not None
|
|
211
|
+
and "project_id" not in model_data
|
|
212
|
+
):
|
|
213
|
+
model_data["project_id"] = self.project_id
|
|
214
|
+
|
|
157
215
|
model = self.Model(**model_data)
|
|
158
216
|
session.add(model)
|
|
159
217
|
await session.flush()
|
|
@@ -176,12 +234,20 @@ class Repository[T: Base]:
|
|
|
176
234
|
|
|
177
235
|
async with db.scoped_session(self.session_maker) as session:
|
|
178
236
|
# Only include valid columns that are provided in entity_data
|
|
179
|
-
model_list = [
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
237
|
+
model_list = []
|
|
238
|
+
for d in data_list:
|
|
239
|
+
model_data = self.get_model_data(d)
|
|
240
|
+
|
|
241
|
+
# Add project_id if applicable and not already provided
|
|
242
|
+
if (
|
|
243
|
+
self.has_project_id
|
|
244
|
+
and self.project_id is not None
|
|
245
|
+
and "project_id" not in model_data
|
|
246
|
+
):
|
|
247
|
+
model_data["project_id"] = self.project_id # pragma: no cover
|
|
248
|
+
|
|
249
|
+
model_list.append(self.Model(**model_data))
|
|
250
|
+
|
|
185
251
|
session.add_all(model_list)
|
|
186
252
|
await session.flush()
|
|
187
253
|
|
|
@@ -237,7 +303,13 @@ class Repository[T: Base]:
|
|
|
237
303
|
"""Delete records matching given IDs."""
|
|
238
304
|
logger.debug(f"Deleting {self.Model.__name__} by ids: {ids}")
|
|
239
305
|
async with db.scoped_session(self.session_maker) as session:
|
|
240
|
-
|
|
306
|
+
conditions = [self.primary_key.in_(ids)]
|
|
307
|
+
|
|
308
|
+
# Add project_id filter if applicable
|
|
309
|
+
if self.has_project_id and self.project_id is not None: # pragma: no cover
|
|
310
|
+
conditions.append(getattr(self.Model, "project_id") == self.project_id)
|
|
311
|
+
|
|
312
|
+
query = delete(self.Model).where(and_(*conditions))
|
|
241
313
|
result = await session.execute(query)
|
|
242
314
|
logger.debug(f"Deleted {result.rowcount} records")
|
|
243
315
|
return result.rowcount
|
|
@@ -247,6 +319,11 @@ class Repository[T: Base]:
|
|
|
247
319
|
logger.debug(f"Deleting {self.Model.__name__} by fields: {filters}")
|
|
248
320
|
async with db.scoped_session(self.session_maker) as session:
|
|
249
321
|
conditions = [getattr(self.Model, field) == value for field, value in filters.items()]
|
|
322
|
+
|
|
323
|
+
# Add project_id filter if applicable
|
|
324
|
+
if self.has_project_id and self.project_id is not None:
|
|
325
|
+
conditions.append(getattr(self.Model, "project_id") == self.project_id)
|
|
326
|
+
|
|
250
327
|
query = delete(self.Model).where(and_(*conditions))
|
|
251
328
|
result = await session.execute(query)
|
|
252
329
|
deleted = result.rowcount > 0
|
|
@@ -258,19 +335,34 @@ class Repository[T: Base]:
|
|
|
258
335
|
async with db.scoped_session(self.session_maker) as session:
|
|
259
336
|
if query is None:
|
|
260
337
|
query = select(func.count()).select_from(self.Model)
|
|
338
|
+
# Add project filter if applicable
|
|
339
|
+
if (
|
|
340
|
+
isinstance(query, Select)
|
|
341
|
+
and self.has_project_id
|
|
342
|
+
and self.project_id is not None
|
|
343
|
+
):
|
|
344
|
+
query = query.where(
|
|
345
|
+
getattr(self.Model, "project_id") == self.project_id
|
|
346
|
+
) # pragma: no cover
|
|
347
|
+
|
|
261
348
|
result = await session.execute(query)
|
|
262
349
|
scalar = result.scalar()
|
|
263
350
|
count = scalar if scalar is not None else 0
|
|
264
351
|
logger.debug(f"Counted {count} {self.Model.__name__} records")
|
|
265
352
|
return count
|
|
266
353
|
|
|
267
|
-
async def execute_query(
|
|
354
|
+
async def execute_query(
|
|
355
|
+
self,
|
|
356
|
+
query: Executable,
|
|
357
|
+
params: Optional[Dict[str, Any]] = None,
|
|
358
|
+
use_query_options: bool = True,
|
|
359
|
+
) -> Result[Any]:
|
|
268
360
|
"""Execute a query asynchronously."""
|
|
269
361
|
|
|
270
362
|
query = query.options(*self.get_load_options()) if use_query_options else query
|
|
271
|
-
logger.
|
|
363
|
+
logger.trace(f"Executing query: {query}, params: {params}")
|
|
272
364
|
async with db.scoped_session(self.session_maker) as session:
|
|
273
|
-
result = await session.execute(query)
|
|
365
|
+
result = await session.execute(query, params)
|
|
274
366
|
return result
|
|
275
367
|
|
|
276
368
|
def get_load_options(self) -> List[LoaderOption]:
|
|
@@ -19,6 +19,7 @@ from basic_memory.schemas.search import SearchItemType
|
|
|
19
19
|
class SearchIndexRow:
|
|
20
20
|
"""Search result with score and metadata."""
|
|
21
21
|
|
|
22
|
+
project_id: int
|
|
22
23
|
id: int
|
|
23
24
|
type: str
|
|
24
25
|
file_path: str
|
|
@@ -47,6 +48,27 @@ class SearchIndexRow:
|
|
|
47
48
|
def content(self):
|
|
48
49
|
return self.content_snippet
|
|
49
50
|
|
|
51
|
+
@property
|
|
52
|
+
def directory(self) -> str:
|
|
53
|
+
"""Extract directory part from file_path.
|
|
54
|
+
|
|
55
|
+
For a file at "projects/notes/ideas.md", returns "/projects/notes"
|
|
56
|
+
For a file at root level "README.md", returns "/"
|
|
57
|
+
"""
|
|
58
|
+
if not self.type == SearchItemType.ENTITY.value and not self.file_path:
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
# Split the path by slashes
|
|
62
|
+
parts = self.file_path.split("/")
|
|
63
|
+
|
|
64
|
+
# If there's only one part (e.g., "README.md"), it's at the root
|
|
65
|
+
if len(parts) <= 1:
|
|
66
|
+
return "/"
|
|
67
|
+
|
|
68
|
+
# Join all parts except the last one (filename)
|
|
69
|
+
directory_path = "/".join(parts[:-1])
|
|
70
|
+
return f"/{directory_path}"
|
|
71
|
+
|
|
50
72
|
def to_insert(self):
|
|
51
73
|
return {
|
|
52
74
|
"id": self.id,
|
|
@@ -64,14 +86,28 @@ class SearchIndexRow:
|
|
|
64
86
|
"category": self.category,
|
|
65
87
|
"created_at": self.created_at if self.created_at else None,
|
|
66
88
|
"updated_at": self.updated_at if self.updated_at else None,
|
|
89
|
+
"project_id": self.project_id,
|
|
67
90
|
}
|
|
68
91
|
|
|
69
92
|
|
|
70
93
|
class SearchRepository:
|
|
71
94
|
"""Repository for search index operations."""
|
|
72
95
|
|
|
73
|
-
def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
|
|
96
|
+
def __init__(self, session_maker: async_sessionmaker[AsyncSession], project_id: int):
|
|
97
|
+
"""Initialize with session maker and project_id filter.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
session_maker: SQLAlchemy session maker
|
|
101
|
+
project_id: Project ID to filter all operations by
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: If project_id is None or invalid
|
|
105
|
+
"""
|
|
106
|
+
if project_id is None or project_id <= 0: # pragma: no cover
|
|
107
|
+
raise ValueError("A valid project_id is required for SearchRepository")
|
|
108
|
+
|
|
74
109
|
self.session_maker = session_maker
|
|
110
|
+
self.project_id = project_id
|
|
75
111
|
|
|
76
112
|
async def init_search_index(self):
|
|
77
113
|
"""Create or recreate the search index."""
|
|
@@ -92,28 +128,93 @@ class SearchRepository:
|
|
|
92
128
|
is_prefix: Whether to add prefix search capability (* suffix)
|
|
93
129
|
|
|
94
130
|
For FTS5:
|
|
95
|
-
-
|
|
96
|
-
- Terms with
|
|
97
|
-
-
|
|
131
|
+
- Boolean operators (AND, OR, NOT) are preserved for complex queries
|
|
132
|
+
- Terms with FTS5 special characters are quoted to prevent syntax errors
|
|
133
|
+
- Simple terms get prefix wildcards for better matching
|
|
98
134
|
"""
|
|
99
|
-
if
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# Check for boolean operators - if present, return the term as is
|
|
103
|
-
boolean_operators = [" AND ", " OR ", " NOT ", "(", ")"]
|
|
135
|
+
# Check for explicit boolean operators - if present, return the term as is
|
|
136
|
+
boolean_operators = [" AND ", " OR ", " NOT "]
|
|
104
137
|
if any(op in f" {term} " for op in boolean_operators):
|
|
105
138
|
return term
|
|
106
139
|
|
|
107
|
-
#
|
|
108
|
-
|
|
140
|
+
# Check if term is already a proper wildcard pattern (alphanumeric + *)
|
|
141
|
+
# e.g., "hello*", "test*world" - these should be left alone
|
|
142
|
+
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
|
|
143
|
+
return term
|
|
109
144
|
|
|
110
|
-
#
|
|
111
|
-
|
|
145
|
+
# Characters that can cause FTS5 syntax errors when used as operators
|
|
146
|
+
# We're more conservative here - only quote when we detect problematic patterns
|
|
147
|
+
problematic_chars = [
|
|
148
|
+
'"',
|
|
149
|
+
"'",
|
|
150
|
+
"(",
|
|
151
|
+
")",
|
|
152
|
+
"[",
|
|
153
|
+
"]",
|
|
154
|
+
"{",
|
|
155
|
+
"}",
|
|
156
|
+
"+",
|
|
157
|
+
"!",
|
|
158
|
+
"@",
|
|
159
|
+
"#",
|
|
160
|
+
"$",
|
|
161
|
+
"%",
|
|
162
|
+
"^",
|
|
163
|
+
"&",
|
|
164
|
+
"=",
|
|
165
|
+
"|",
|
|
166
|
+
"\\",
|
|
167
|
+
"~",
|
|
168
|
+
"`",
|
|
169
|
+
]
|
|
112
170
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
171
|
+
# Characters that indicate we should quote (spaces, dots, colons, etc.)
|
|
172
|
+
# Adding hyphens here because FTS5 can have issues with hyphens followed by wildcards
|
|
173
|
+
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-"]
|
|
174
|
+
|
|
175
|
+
# Check if term needs quoting
|
|
176
|
+
has_problematic = any(c in term for c in problematic_chars)
|
|
177
|
+
has_spaces_or_special = any(c in term for c in needs_quoting_chars)
|
|
178
|
+
|
|
179
|
+
if has_problematic or has_spaces_or_special:
|
|
180
|
+
# Handle multi-word queries differently from special character queries
|
|
181
|
+
if " " in term and not any(c in term for c in problematic_chars):
|
|
182
|
+
# Check if any individual word contains special characters that need quoting
|
|
183
|
+
words = term.strip().split()
|
|
184
|
+
has_special_in_words = any(
|
|
185
|
+
any(c in word for c in needs_quoting_chars if c != " ") for word in words
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if not has_special_in_words:
|
|
189
|
+
# For multi-word queries with simple words (like "emoji unicode"),
|
|
190
|
+
# use boolean AND to handle word order variations
|
|
191
|
+
if is_prefix:
|
|
192
|
+
# Add prefix wildcard to each word for better matching
|
|
193
|
+
prepared_words = [f"{word}*" for word in words if word]
|
|
194
|
+
else:
|
|
195
|
+
prepared_words = words
|
|
196
|
+
term = " AND ".join(prepared_words)
|
|
197
|
+
else:
|
|
198
|
+
# If any word has special characters, quote the entire phrase
|
|
199
|
+
escaped_term = term.replace('"', '""')
|
|
200
|
+
if is_prefix and not ("/" in term and term.endswith(".md")):
|
|
201
|
+
term = f'"{escaped_term}"*'
|
|
202
|
+
else:
|
|
203
|
+
term = f'"{escaped_term}"'
|
|
204
|
+
else:
|
|
205
|
+
# For terms with problematic characters or file paths, use exact phrase matching
|
|
206
|
+
# Escape any existing quotes by doubling them
|
|
207
|
+
escaped_term = term.replace('"', '""')
|
|
208
|
+
# Quote the entire term to handle special characters safely
|
|
209
|
+
if is_prefix and not ("/" in term and term.endswith(".md")):
|
|
210
|
+
# For search terms (not file paths), add prefix matching
|
|
211
|
+
term = f'"{escaped_term}"*'
|
|
212
|
+
else:
|
|
213
|
+
# For file paths, use exact matching
|
|
214
|
+
term = f'"{escaped_term}"'
|
|
215
|
+
elif is_prefix:
|
|
216
|
+
# Only add wildcard for simple terms without special characters
|
|
217
|
+
term = f"{term}*"
|
|
117
218
|
|
|
118
219
|
return term
|
|
119
220
|
|
|
@@ -125,7 +226,7 @@ class SearchRepository:
|
|
|
125
226
|
title: Optional[str] = None,
|
|
126
227
|
types: Optional[List[str]] = None,
|
|
127
228
|
after_date: Optional[datetime] = None,
|
|
128
|
-
|
|
229
|
+
search_item_types: Optional[List[SearchItemType]] = None,
|
|
129
230
|
limit: int = 10,
|
|
130
231
|
offset: int = 0,
|
|
131
232
|
) -> List[SearchIndexRow]:
|
|
@@ -136,26 +237,30 @@ class SearchRepository:
|
|
|
136
237
|
|
|
137
238
|
# Handle text search for title and content
|
|
138
239
|
if search_text:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if has_boolean:
|
|
144
|
-
# If boolean operators are present, use the raw query
|
|
145
|
-
# No need to prepare it, FTS5 will understand the operators
|
|
146
|
-
params["text"] = search_text
|
|
147
|
-
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
|
240
|
+
# Skip FTS for wildcard-only queries that would cause "unknown special query" errors
|
|
241
|
+
if search_text.strip() == "*" or search_text.strip() == "":
|
|
242
|
+
# For wildcard searches, don't add any text conditions - return all results
|
|
243
|
+
pass
|
|
148
244
|
else:
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
245
|
+
# Check for explicit boolean operators - only detect them in proper boolean contexts
|
|
246
|
+
has_boolean = any(op in f" {search_text} " for op in [" AND ", " OR ", " NOT "])
|
|
247
|
+
|
|
248
|
+
if has_boolean:
|
|
249
|
+
# If boolean operators are present, use the raw query
|
|
250
|
+
# No need to prepare it, FTS5 will understand the operators
|
|
251
|
+
params["text"] = search_text
|
|
252
|
+
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
|
253
|
+
else:
|
|
254
|
+
# Standard search with term preparation
|
|
255
|
+
processed_text = self._prepare_search_term(search_text.strip())
|
|
256
|
+
params["text"] = processed_text
|
|
257
|
+
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
|
153
258
|
|
|
154
259
|
# Handle title match search
|
|
155
260
|
if title:
|
|
156
|
-
title_text = self._prepare_search_term(title.strip())
|
|
157
|
-
params["
|
|
158
|
-
conditions.append("title MATCH :
|
|
261
|
+
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
|
|
262
|
+
params["title_text"] = title_text
|
|
263
|
+
conditions.append("title MATCH :title_text")
|
|
159
264
|
|
|
160
265
|
# Handle permalink exact search
|
|
161
266
|
if permalink:
|
|
@@ -164,19 +269,25 @@ class SearchRepository:
|
|
|
164
269
|
|
|
165
270
|
# Handle permalink match search, supports *
|
|
166
271
|
if permalink_match:
|
|
167
|
-
#
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
)
|
|
272
|
+
# For GLOB patterns, don't use _prepare_search_term as it will quote slashes
|
|
273
|
+
# GLOB patterns need to preserve their syntax
|
|
274
|
+
permalink_text = permalink_match.lower().strip()
|
|
171
275
|
params["permalink"] = permalink_text
|
|
172
276
|
if "*" in permalink_match:
|
|
173
277
|
conditions.append("permalink GLOB :permalink")
|
|
174
278
|
else:
|
|
175
|
-
|
|
279
|
+
# For exact matches without *, we can use FTS5 MATCH
|
|
280
|
+
# but only prepare the term if it doesn't look like a path
|
|
281
|
+
if "/" in permalink_text:
|
|
282
|
+
conditions.append("permalink = :permalink")
|
|
283
|
+
else:
|
|
284
|
+
permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
|
|
285
|
+
params["permalink"] = permalink_text
|
|
286
|
+
conditions.append("permalink MATCH :permalink")
|
|
176
287
|
|
|
177
288
|
# Handle entity type filter
|
|
178
|
-
if
|
|
179
|
-
type_list = ", ".join(f"'{t.value}'" for t in
|
|
289
|
+
if search_item_types:
|
|
290
|
+
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
|
|
180
291
|
conditions.append(f"type IN ({type_list})")
|
|
181
292
|
|
|
182
293
|
# Handle type filter
|
|
@@ -192,6 +303,10 @@ class SearchRepository:
|
|
|
192
303
|
# order by most recent first
|
|
193
304
|
order_by_clause = ", updated_at DESC"
|
|
194
305
|
|
|
306
|
+
# Always filter by project_id
|
|
307
|
+
params["project_id"] = self.project_id
|
|
308
|
+
conditions.append("project_id = :project_id")
|
|
309
|
+
|
|
195
310
|
# set limit on search query
|
|
196
311
|
params["limit"] = limit
|
|
197
312
|
params["offset"] = offset
|
|
@@ -201,6 +316,7 @@ class SearchRepository:
|
|
|
201
316
|
|
|
202
317
|
sql = f"""
|
|
203
318
|
SELECT
|
|
319
|
+
project_id,
|
|
204
320
|
id,
|
|
205
321
|
title,
|
|
206
322
|
permalink,
|
|
@@ -224,12 +340,24 @@ class SearchRepository:
|
|
|
224
340
|
"""
|
|
225
341
|
|
|
226
342
|
logger.trace(f"Search {sql} params: {params}")
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
343
|
+
try:
|
|
344
|
+
async with db.scoped_session(self.session_maker) as session:
|
|
345
|
+
result = await session.execute(text(sql), params)
|
|
346
|
+
rows = result.fetchall()
|
|
347
|
+
except Exception as e:
|
|
348
|
+
# Handle FTS5 syntax errors and provide user-friendly feedback
|
|
349
|
+
if "fts5: syntax error" in str(e).lower(): # pragma: no cover
|
|
350
|
+
logger.warning(f"FTS5 syntax error for search term: {search_text}, error: {e}")
|
|
351
|
+
# Return empty results rather than crashing
|
|
352
|
+
return []
|
|
353
|
+
else:
|
|
354
|
+
# Re-raise other database errors
|
|
355
|
+
logger.error(f"Database error during search: {e}")
|
|
356
|
+
raise
|
|
230
357
|
|
|
231
358
|
results = [
|
|
232
359
|
SearchIndexRow(
|
|
360
|
+
project_id=self.project_id,
|
|
233
361
|
id=row.id,
|
|
234
362
|
title=row.title,
|
|
235
363
|
permalink=row.permalink,
|
|
@@ -249,10 +377,10 @@ class SearchRepository:
|
|
|
249
377
|
for row in rows
|
|
250
378
|
]
|
|
251
379
|
|
|
252
|
-
logger.
|
|
380
|
+
logger.trace(f"Found {len(results)} search results")
|
|
253
381
|
for r in results:
|
|
254
|
-
logger.
|
|
255
|
-
f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
|
|
382
|
+
logger.trace(
|
|
383
|
+
f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
|
|
256
384
|
)
|
|
257
385
|
|
|
258
386
|
return results
|
|
@@ -269,6 +397,10 @@ class SearchRepository:
|
|
|
269
397
|
{"permalink": search_index_row.permalink},
|
|
270
398
|
)
|
|
271
399
|
|
|
400
|
+
# Prepare data for insert with project_id
|
|
401
|
+
insert_data = search_index_row.to_insert()
|
|
402
|
+
insert_data["project_id"] = self.project_id
|
|
403
|
+
|
|
272
404
|
# Insert new record
|
|
273
405
|
await session.execute(
|
|
274
406
|
text("""
|
|
@@ -276,15 +408,17 @@ class SearchRepository:
|
|
|
276
408
|
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
|
|
277
409
|
from_id, to_id, relation_type,
|
|
278
410
|
entity_id, category,
|
|
279
|
-
created_at, updated_at
|
|
411
|
+
created_at, updated_at,
|
|
412
|
+
project_id
|
|
280
413
|
) VALUES (
|
|
281
414
|
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
|
|
282
415
|
:from_id, :to_id, :relation_type,
|
|
283
416
|
:entity_id, :category,
|
|
284
|
-
:created_at, :updated_at
|
|
417
|
+
:created_at, :updated_at,
|
|
418
|
+
:project_id
|
|
285
419
|
)
|
|
286
420
|
"""),
|
|
287
|
-
|
|
421
|
+
insert_data,
|
|
288
422
|
)
|
|
289
423
|
logger.debug(f"indexed row {search_index_row}")
|
|
290
424
|
await session.commit()
|
|
@@ -293,8 +427,10 @@ class SearchRepository:
|
|
|
293
427
|
"""Delete an item from the search index by entity_id."""
|
|
294
428
|
async with db.scoped_session(self.session_maker) as session:
|
|
295
429
|
await session.execute(
|
|
296
|
-
text(
|
|
297
|
-
|
|
430
|
+
text(
|
|
431
|
+
"DELETE FROM search_index WHERE entity_id = :entity_id AND project_id = :project_id"
|
|
432
|
+
),
|
|
433
|
+
{"entity_id": entity_id, "project_id": self.project_id},
|
|
298
434
|
)
|
|
299
435
|
await session.commit()
|
|
300
436
|
|
|
@@ -302,8 +438,10 @@ class SearchRepository:
|
|
|
302
438
|
"""Delete an item from the search index."""
|
|
303
439
|
async with db.scoped_session(self.session_maker) as session:
|
|
304
440
|
await session.execute(
|
|
305
|
-
text(
|
|
306
|
-
|
|
441
|
+
text(
|
|
442
|
+
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
|
|
443
|
+
),
|
|
444
|
+
{"permalink": permalink, "project_id": self.project_id},
|
|
307
445
|
)
|
|
308
446
|
await session.commit()
|
|
309
447
|
|
basic_memory/schemas/__init__.py
CHANGED
|
@@ -44,6 +44,10 @@ from basic_memory.schemas.project_info import (
|
|
|
44
44
|
ProjectInfoResponse,
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
+
from basic_memory.schemas.directory import (
|
|
48
|
+
DirectoryNode,
|
|
49
|
+
)
|
|
50
|
+
|
|
47
51
|
# For convenient imports, export all models
|
|
48
52
|
__all__ = [
|
|
49
53
|
# Base
|
|
@@ -71,4 +75,6 @@ __all__ = [
|
|
|
71
75
|
"ActivityMetrics",
|
|
72
76
|
"SystemStatus",
|
|
73
77
|
"ProjectInfoResponse",
|
|
78
|
+
# Directory
|
|
79
|
+
"DirectoryNode",
|
|
74
80
|
]
|