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.

Files changed (117) hide show
  1. basic_memory/__init__.py +2 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  5. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
  6. basic_memory/api/app.py +43 -13
  7. basic_memory/api/routers/__init__.py +4 -2
  8. basic_memory/api/routers/directory_router.py +63 -0
  9. basic_memory/api/routers/importer_router.py +152 -0
  10. basic_memory/api/routers/knowledge_router.py +139 -37
  11. basic_memory/api/routers/management_router.py +78 -0
  12. basic_memory/api/routers/memory_router.py +6 -62
  13. basic_memory/api/routers/project_router.py +234 -0
  14. basic_memory/api/routers/prompt_router.py +260 -0
  15. basic_memory/api/routers/search_router.py +3 -21
  16. basic_memory/api/routers/utils.py +130 -0
  17. basic_memory/api/template_loader.py +292 -0
  18. basic_memory/cli/app.py +20 -21
  19. basic_memory/cli/commands/__init__.py +2 -1
  20. basic_memory/cli/commands/auth.py +136 -0
  21. basic_memory/cli/commands/db.py +3 -3
  22. basic_memory/cli/commands/import_chatgpt.py +31 -207
  23. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  24. basic_memory/cli/commands/import_claude_projects.py +33 -143
  25. basic_memory/cli/commands/import_memory_json.py +26 -83
  26. basic_memory/cli/commands/mcp.py +71 -18
  27. basic_memory/cli/commands/project.py +102 -70
  28. basic_memory/cli/commands/status.py +19 -9
  29. basic_memory/cli/commands/sync.py +44 -58
  30. basic_memory/cli/commands/tool.py +6 -6
  31. basic_memory/cli/main.py +1 -5
  32. basic_memory/config.py +143 -87
  33. basic_memory/db.py +6 -4
  34. basic_memory/deps.py +227 -30
  35. basic_memory/importers/__init__.py +27 -0
  36. basic_memory/importers/base.py +79 -0
  37. basic_memory/importers/chatgpt_importer.py +222 -0
  38. basic_memory/importers/claude_conversations_importer.py +172 -0
  39. basic_memory/importers/claude_projects_importer.py +148 -0
  40. basic_memory/importers/memory_json_importer.py +93 -0
  41. basic_memory/importers/utils.py +58 -0
  42. basic_memory/markdown/entity_parser.py +5 -2
  43. basic_memory/mcp/auth_provider.py +270 -0
  44. basic_memory/mcp/external_auth_provider.py +321 -0
  45. basic_memory/mcp/project_session.py +103 -0
  46. basic_memory/mcp/prompts/__init__.py +2 -0
  47. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  48. basic_memory/mcp/prompts/recent_activity.py +20 -4
  49. basic_memory/mcp/prompts/search.py +14 -140
  50. basic_memory/mcp/prompts/sync_status.py +116 -0
  51. basic_memory/mcp/prompts/utils.py +3 -3
  52. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  53. basic_memory/mcp/server.py +86 -13
  54. basic_memory/mcp/supabase_auth_provider.py +463 -0
  55. basic_memory/mcp/tools/__init__.py +24 -0
  56. basic_memory/mcp/tools/build_context.py +43 -8
  57. basic_memory/mcp/tools/canvas.py +17 -3
  58. basic_memory/mcp/tools/delete_note.py +168 -5
  59. basic_memory/mcp/tools/edit_note.py +303 -0
  60. basic_memory/mcp/tools/list_directory.py +154 -0
  61. basic_memory/mcp/tools/move_note.py +299 -0
  62. basic_memory/mcp/tools/project_management.py +332 -0
  63. basic_memory/mcp/tools/read_content.py +15 -6
  64. basic_memory/mcp/tools/read_note.py +28 -9
  65. basic_memory/mcp/tools/recent_activity.py +47 -16
  66. basic_memory/mcp/tools/search.py +189 -8
  67. basic_memory/mcp/tools/sync_status.py +254 -0
  68. basic_memory/mcp/tools/utils.py +184 -12
  69. basic_memory/mcp/tools/view_note.py +66 -0
  70. basic_memory/mcp/tools/write_note.py +24 -17
  71. basic_memory/models/__init__.py +3 -2
  72. basic_memory/models/knowledge.py +16 -4
  73. basic_memory/models/project.py +78 -0
  74. basic_memory/models/search.py +8 -5
  75. basic_memory/repository/__init__.py +2 -0
  76. basic_memory/repository/entity_repository.py +8 -3
  77. basic_memory/repository/observation_repository.py +35 -3
  78. basic_memory/repository/project_info_repository.py +3 -2
  79. basic_memory/repository/project_repository.py +85 -0
  80. basic_memory/repository/relation_repository.py +8 -2
  81. basic_memory/repository/repository.py +107 -15
  82. basic_memory/repository/search_repository.py +192 -54
  83. basic_memory/schemas/__init__.py +6 -0
  84. basic_memory/schemas/base.py +33 -5
  85. basic_memory/schemas/directory.py +30 -0
  86. basic_memory/schemas/importer.py +34 -0
  87. basic_memory/schemas/memory.py +84 -13
  88. basic_memory/schemas/project_info.py +112 -2
  89. basic_memory/schemas/prompt.py +90 -0
  90. basic_memory/schemas/request.py +56 -2
  91. basic_memory/schemas/search.py +1 -1
  92. basic_memory/services/__init__.py +2 -1
  93. basic_memory/services/context_service.py +208 -95
  94. basic_memory/services/directory_service.py +167 -0
  95. basic_memory/services/entity_service.py +399 -6
  96. basic_memory/services/exceptions.py +6 -0
  97. basic_memory/services/file_service.py +14 -15
  98. basic_memory/services/initialization.py +170 -66
  99. basic_memory/services/link_resolver.py +35 -12
  100. basic_memory/services/migration_service.py +168 -0
  101. basic_memory/services/project_service.py +671 -0
  102. basic_memory/services/search_service.py +77 -2
  103. basic_memory/services/sync_status_service.py +181 -0
  104. basic_memory/sync/background_sync.py +25 -0
  105. basic_memory/sync/sync_service.py +102 -21
  106. basic_memory/sync/watch_service.py +63 -39
  107. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  108. basic_memory/templates/prompts/search.hbs +101 -0
  109. basic_memory/utils.py +67 -17
  110. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/METADATA +26 -4
  111. basic_memory-0.13.0.dist-info/RECORD +138 -0
  112. basic_memory/api/routers/project_info_router.py +0 -274
  113. basic_memory/mcp/main.py +0 -24
  114. basic_memory-0.12.2.dist-info/RECORD +0 -100
  115. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
  116. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
  117. {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__(self, session_maker: async_sessionmaker[AsyncSession], Model: Type[T]):
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
- return select(*entities)
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.debug(f"Found {self.Model.__name__}: {getattr(entity, 'id', None)}")
195
+ logger.trace(f"Found {self.Model.__name__}: {getattr(entity, 'id', None)}")
147
196
  else:
148
- logger.debug(f"No {self.Model.__name__} found")
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
- self.Model(
181
- **self.get_model_data(d),
182
- )
183
- for d in data_list
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
- query = delete(self.Model).where(self.primary_key.in_(ids))
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(self, query: Executable, use_query_options: bool = True) -> Result[Any]:
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.debug(f"Executing query: {query}")
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
- - Special characters and phrases need to be quoted
96
- - Terms with spaces or special chars need quotes
97
- - Boolean operators (AND, OR, NOT) and parentheses are preserved
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 "*" in term:
100
- return term
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
- # List of special characters that need quoting (excluding *)
108
- special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
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
- # Check if term contains any special characters
111
- needs_quotes = any(c in term for c in special_chars)
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
- if needs_quotes:
114
- # If the term already contains quotes, escape them and add a wildcard
115
- term = term.replace('"', '""')
116
- term = f'"{term}"*'
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
- entity_types: Optional[List[SearchItemType]] = None,
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
- has_boolean = any(
140
- op in f" {search_text} " for op in [" AND ", " OR ", " NOT ", "(", ")"]
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
- # Standard search with term preparation
150
- processed_text = self._prepare_search_term(search_text.strip())
151
- params["text"] = processed_text
152
- conditions.append("(title MATCH :text OR content_stems MATCH :text)")
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["text"] = title_text
158
- conditions.append("title MATCH :text")
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
- # Clean and prepare permalink for FTS5 GLOB match
168
- permalink_text = self._prepare_search_term(
169
- permalink_match.lower().strip(), is_prefix=False
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
- conditions.append("permalink MATCH :permalink")
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 entity_types:
179
- type_list = ", ".join(f"'{t.value}'" for t in entity_types)
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
- async with db.scoped_session(self.session_maker) as session:
228
- result = await session.execute(text(sql), params)
229
- rows = result.fetchall()
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.debug(f"Found {len(results)} search results")
380
+ logger.trace(f"Found {len(results)} search results")
253
381
  for r in results:
254
- logger.debug(
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
- search_index_row.to_insert(),
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("DELETE FROM search_index WHERE entity_id = :entity_id"),
297
- {"entity_id": entity_id},
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("DELETE FROM search_index WHERE permalink = :permalink"),
306
- {"permalink": permalink},
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
 
@@ -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
  ]