basic-memory 0.12.3__py3-none-any.whl → 0.13.0b1__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 (107) hide show
  1. basic_memory/__init__.py +7 -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/cc7172b46608_update_search_index_schema.py +0 -5
  5. basic_memory/api/app.py +43 -13
  6. basic_memory/api/routers/__init__.py +4 -2
  7. basic_memory/api/routers/directory_router.py +63 -0
  8. basic_memory/api/routers/importer_router.py +152 -0
  9. basic_memory/api/routers/knowledge_router.py +127 -38
  10. basic_memory/api/routers/management_router.py +78 -0
  11. basic_memory/api/routers/memory_router.py +4 -59
  12. basic_memory/api/routers/project_router.py +230 -0
  13. basic_memory/api/routers/prompt_router.py +260 -0
  14. basic_memory/api/routers/search_router.py +3 -21
  15. basic_memory/api/routers/utils.py +130 -0
  16. basic_memory/api/template_loader.py +292 -0
  17. basic_memory/cli/app.py +20 -21
  18. basic_memory/cli/commands/__init__.py +2 -1
  19. basic_memory/cli/commands/auth.py +136 -0
  20. basic_memory/cli/commands/db.py +3 -3
  21. basic_memory/cli/commands/import_chatgpt.py +31 -207
  22. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  23. basic_memory/cli/commands/import_claude_projects.py +33 -143
  24. basic_memory/cli/commands/import_memory_json.py +26 -83
  25. basic_memory/cli/commands/mcp.py +71 -18
  26. basic_memory/cli/commands/project.py +99 -67
  27. basic_memory/cli/commands/status.py +19 -9
  28. basic_memory/cli/commands/sync.py +44 -58
  29. basic_memory/cli/main.py +1 -5
  30. basic_memory/config.py +145 -88
  31. basic_memory/db.py +6 -4
  32. basic_memory/deps.py +227 -30
  33. basic_memory/importers/__init__.py +27 -0
  34. basic_memory/importers/base.py +79 -0
  35. basic_memory/importers/chatgpt_importer.py +222 -0
  36. basic_memory/importers/claude_conversations_importer.py +172 -0
  37. basic_memory/importers/claude_projects_importer.py +148 -0
  38. basic_memory/importers/memory_json_importer.py +93 -0
  39. basic_memory/importers/utils.py +58 -0
  40. basic_memory/markdown/entity_parser.py +5 -2
  41. basic_memory/mcp/auth_provider.py +270 -0
  42. basic_memory/mcp/external_auth_provider.py +321 -0
  43. basic_memory/mcp/project_session.py +103 -0
  44. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  45. basic_memory/mcp/prompts/recent_activity.py +19 -3
  46. basic_memory/mcp/prompts/search.py +14 -140
  47. basic_memory/mcp/prompts/utils.py +3 -3
  48. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  49. basic_memory/mcp/server.py +82 -8
  50. basic_memory/mcp/supabase_auth_provider.py +463 -0
  51. basic_memory/mcp/tools/__init__.py +20 -0
  52. basic_memory/mcp/tools/build_context.py +11 -1
  53. basic_memory/mcp/tools/canvas.py +15 -2
  54. basic_memory/mcp/tools/delete_note.py +12 -4
  55. basic_memory/mcp/tools/edit_note.py +297 -0
  56. basic_memory/mcp/tools/list_directory.py +154 -0
  57. basic_memory/mcp/tools/move_note.py +87 -0
  58. basic_memory/mcp/tools/project_management.py +300 -0
  59. basic_memory/mcp/tools/read_content.py +15 -6
  60. basic_memory/mcp/tools/read_note.py +17 -5
  61. basic_memory/mcp/tools/recent_activity.py +11 -2
  62. basic_memory/mcp/tools/search.py +10 -1
  63. basic_memory/mcp/tools/utils.py +137 -12
  64. basic_memory/mcp/tools/write_note.py +11 -15
  65. basic_memory/models/__init__.py +3 -2
  66. basic_memory/models/knowledge.py +16 -4
  67. basic_memory/models/project.py +80 -0
  68. basic_memory/models/search.py +8 -5
  69. basic_memory/repository/__init__.py +2 -0
  70. basic_memory/repository/entity_repository.py +8 -3
  71. basic_memory/repository/observation_repository.py +35 -3
  72. basic_memory/repository/project_info_repository.py +3 -2
  73. basic_memory/repository/project_repository.py +85 -0
  74. basic_memory/repository/relation_repository.py +8 -2
  75. basic_memory/repository/repository.py +107 -15
  76. basic_memory/repository/search_repository.py +87 -27
  77. basic_memory/schemas/__init__.py +6 -0
  78. basic_memory/schemas/directory.py +30 -0
  79. basic_memory/schemas/importer.py +34 -0
  80. basic_memory/schemas/memory.py +26 -12
  81. basic_memory/schemas/project_info.py +112 -2
  82. basic_memory/schemas/prompt.py +90 -0
  83. basic_memory/schemas/request.py +56 -2
  84. basic_memory/schemas/search.py +1 -1
  85. basic_memory/services/__init__.py +2 -1
  86. basic_memory/services/context_service.py +208 -95
  87. basic_memory/services/directory_service.py +167 -0
  88. basic_memory/services/entity_service.py +385 -5
  89. basic_memory/services/exceptions.py +6 -0
  90. basic_memory/services/file_service.py +14 -15
  91. basic_memory/services/initialization.py +144 -67
  92. basic_memory/services/link_resolver.py +16 -8
  93. basic_memory/services/project_service.py +548 -0
  94. basic_memory/services/search_service.py +77 -2
  95. basic_memory/sync/background_sync.py +25 -0
  96. basic_memory/sync/sync_service.py +10 -9
  97. basic_memory/sync/watch_service.py +63 -39
  98. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  99. basic_memory/templates/prompts/search.hbs +101 -0
  100. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/METADATA +23 -1
  101. basic_memory-0.13.0b1.dist-info/RECORD +132 -0
  102. basic_memory/api/routers/project_info_router.py +0 -274
  103. basic_memory/mcp/main.py +0 -24
  104. basic_memory-0.12.3.dist-info/RECORD +0 -100
  105. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/WHEEL +0 -0
  106. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/entry_points.txt +0 -0
  107. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,85 @@
1
+ """Repository for managing projects in Basic Memory."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional, Sequence, Union
5
+
6
+ from sqlalchemy import text
7
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
8
+
9
+ from basic_memory import db
10
+ from basic_memory.models.project import Project
11
+ from basic_memory.repository.repository import Repository
12
+
13
+
14
+ class ProjectRepository(Repository[Project]):
15
+ """Repository for Project model.
16
+
17
+ Projects represent collections of knowledge entities grouped together.
18
+ Each entity, observation, and relation belongs to a specific project.
19
+ """
20
+
21
+ def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
22
+ """Initialize with session maker."""
23
+ super().__init__(session_maker, Project)
24
+
25
+ async def get_by_name(self, name: str) -> Optional[Project]:
26
+ """Get project by name.
27
+
28
+ Args:
29
+ name: Unique name of the project
30
+ """
31
+ query = self.select().where(Project.name == name)
32
+ return await self.find_one(query)
33
+
34
+ async def get_by_permalink(self, permalink: str) -> Optional[Project]:
35
+ """Get project by permalink.
36
+
37
+ Args:
38
+ permalink: URL-friendly identifier for the project
39
+ """
40
+ query = self.select().where(Project.permalink == permalink)
41
+ return await self.find_one(query)
42
+
43
+ async def get_by_path(self, path: Union[Path, str]) -> Optional[Project]:
44
+ """Get project by filesystem path.
45
+
46
+ Args:
47
+ path: Path to the project directory (will be converted to string internally)
48
+ """
49
+ query = self.select().where(Project.path == str(path))
50
+ return await self.find_one(query)
51
+
52
+ async def get_default_project(self) -> Optional[Project]:
53
+ """Get the default project (the one marked as is_default=True)."""
54
+ query = self.select().where(Project.is_default.is_not(None))
55
+ return await self.find_one(query)
56
+
57
+ async def get_active_projects(self) -> Sequence[Project]:
58
+ """Get all active projects."""
59
+ query = self.select().where(Project.is_active == True) # noqa: E712
60
+ result = await self.execute_query(query)
61
+ return list(result.scalars().all())
62
+
63
+ async def set_as_default(self, project_id: int) -> Optional[Project]:
64
+ """Set a project as the default and unset previous default.
65
+
66
+ Args:
67
+ project_id: ID of the project to set as default
68
+
69
+ Returns:
70
+ The updated project if found, None otherwise
71
+ """
72
+ async with db.scoped_session(self.session_maker) as session:
73
+ # First, clear the default flag for all projects using direct SQL
74
+ await session.execute(
75
+ text("UPDATE project SET is_default = NULL WHERE is_default IS NOT NULL")
76
+ )
77
+ await session.flush()
78
+
79
+ # Set the new default project
80
+ target_project = await self.select_by_id(session, project_id)
81
+ if target_project:
82
+ target_project.is_default = True
83
+ await session.flush()
84
+ return target_project
85
+ return None # pragma: no cover
@@ -16,8 +16,14 @@ from basic_memory.repository.repository import Repository
16
16
  class RelationRepository(Repository[Relation]):
17
17
  """Repository for Relation model with memory-specific operations."""
18
18
 
19
- def __init__(self, session_maker: async_sessionmaker):
20
- super().__init__(session_maker, Relation)
19
+ def __init__(self, session_maker: async_sessionmaker, project_id: int):
20
+ """Initialize with session maker and project_id filter.
21
+
22
+ Args:
23
+ session_maker: SQLAlchemy session maker
24
+ project_id: Project ID to filter all operations by
25
+ """
26
+ super().__init__(session_maker, Relation, project_id=project_id)
21
27
 
22
28
  async def find_relation(
23
29
  self, from_permalink: str, to_permalink: str, relation_type: str
@@ -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."""
@@ -94,26 +130,35 @@ class SearchRepository:
94
130
  For FTS5:
95
131
  - Special characters and phrases need to be quoted
96
132
  - Terms with spaces or special chars need quotes
97
- - Boolean operators (AND, OR, NOT) and parentheses are preserved
133
+ - Boolean operators (AND, OR, NOT) are preserved for complex queries
98
134
  """
99
135
  if "*" in term:
100
136
  return term
101
137
 
102
- # Check for boolean operators - if present, return the term as is
103
- boolean_operators = [" AND ", " OR ", " NOT ", "(", ")"]
138
+ # Check for explicit boolean operators - if present, return the term as is
139
+ boolean_operators = [" AND ", " OR ", " NOT "]
104
140
  if any(op in f" {term} " for op in boolean_operators):
105
141
  return term
106
142
 
107
- # List of special characters that need quoting (excluding *)
143
+ # List of FTS5 special characters that need escaping/quoting
108
144
  special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
109
145
 
110
146
  # Check if term contains any special characters
111
147
  needs_quotes = any(c in term for c in special_chars)
112
148
 
113
149
  if needs_quotes:
114
- # If the term already contains quotes, escape them and add a wildcard
115
- term = term.replace('"', '""')
116
- term = f'"{term}"*'
150
+ # Escape any existing quotes by doubling them
151
+ escaped_term = term.replace('"', '""')
152
+ # Quote the entire term to handle special characters safely
153
+ if is_prefix and not ("/" in term and term.endswith(".md")):
154
+ # For search terms (not file paths), add prefix matching
155
+ term = f'"{escaped_term}"*'
156
+ else:
157
+ # For file paths, use exact matching
158
+ term = f'"{escaped_term}"'
159
+ elif is_prefix:
160
+ # Only add wildcard for simple terms without special characters
161
+ term = f"{term}*"
117
162
 
118
163
  return term
119
164
 
@@ -125,7 +170,7 @@ class SearchRepository:
125
170
  title: Optional[str] = None,
126
171
  types: Optional[List[str]] = None,
127
172
  after_date: Optional[datetime] = None,
128
- entity_types: Optional[List[SearchItemType]] = None,
173
+ search_item_types: Optional[List[SearchItemType]] = None,
129
174
  limit: int = 10,
130
175
  offset: int = 0,
131
176
  ) -> List[SearchIndexRow]:
@@ -136,9 +181,8 @@ class SearchRepository:
136
181
 
137
182
  # Handle text search for title and content
138
183
  if search_text:
139
- has_boolean = any(
140
- op in f" {search_text} " for op in [" AND ", " OR ", " NOT ", "(", ")"]
141
- )
184
+ # Check for explicit boolean operators - only detect them in proper boolean contexts
185
+ has_boolean = any(op in f" {search_text} " for op in [" AND ", " OR ", " NOT "])
142
186
 
143
187
  if has_boolean:
144
188
  # If boolean operators are present, use the raw query
@@ -153,9 +197,9 @@ class SearchRepository:
153
197
 
154
198
  # Handle title match search
155
199
  if title:
156
- title_text = self._prepare_search_term(title.strip())
157
- params["text"] = title_text
158
- conditions.append("title MATCH :text")
200
+ title_text = self._prepare_search_term(title.strip(), is_prefix=False)
201
+ params["title_text"] = title_text
202
+ conditions.append("title MATCH :title_text")
159
203
 
160
204
  # Handle permalink exact search
161
205
  if permalink:
@@ -175,8 +219,8 @@ class SearchRepository:
175
219
  conditions.append("permalink MATCH :permalink")
176
220
 
177
221
  # Handle entity type filter
178
- if entity_types:
179
- type_list = ", ".join(f"'{t.value}'" for t in entity_types)
222
+ if search_item_types:
223
+ type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
180
224
  conditions.append(f"type IN ({type_list})")
181
225
 
182
226
  # Handle type filter
@@ -192,6 +236,10 @@ class SearchRepository:
192
236
  # order by most recent first
193
237
  order_by_clause = ", updated_at DESC"
194
238
 
239
+ # Always filter by project_id
240
+ params["project_id"] = self.project_id
241
+ conditions.append("project_id = :project_id")
242
+
195
243
  # set limit on search query
196
244
  params["limit"] = limit
197
245
  params["offset"] = offset
@@ -201,6 +249,7 @@ class SearchRepository:
201
249
 
202
250
  sql = f"""
203
251
  SELECT
252
+ project_id,
204
253
  id,
205
254
  title,
206
255
  permalink,
@@ -230,6 +279,7 @@ class SearchRepository:
230
279
 
231
280
  results = [
232
281
  SearchIndexRow(
282
+ project_id=self.project_id,
233
283
  id=row.id,
234
284
  title=row.title,
235
285
  permalink=row.permalink,
@@ -249,10 +299,10 @@ class SearchRepository:
249
299
  for row in rows
250
300
  ]
251
301
 
252
- logger.debug(f"Found {len(results)} search results")
302
+ logger.trace(f"Found {len(results)} search results")
253
303
  for r in results:
254
- logger.debug(
255
- f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
304
+ logger.trace(
305
+ f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
256
306
  )
257
307
 
258
308
  return results
@@ -269,6 +319,10 @@ class SearchRepository:
269
319
  {"permalink": search_index_row.permalink},
270
320
  )
271
321
 
322
+ # Prepare data for insert with project_id
323
+ insert_data = search_index_row.to_insert()
324
+ insert_data["project_id"] = self.project_id
325
+
272
326
  # Insert new record
273
327
  await session.execute(
274
328
  text("""
@@ -276,15 +330,17 @@ class SearchRepository:
276
330
  id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
277
331
  from_id, to_id, relation_type,
278
332
  entity_id, category,
279
- created_at, updated_at
333
+ created_at, updated_at,
334
+ project_id
280
335
  ) VALUES (
281
336
  :id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
282
337
  :from_id, :to_id, :relation_type,
283
338
  :entity_id, :category,
284
- :created_at, :updated_at
339
+ :created_at, :updated_at,
340
+ :project_id
285
341
  )
286
342
  """),
287
- search_index_row.to_insert(),
343
+ insert_data,
288
344
  )
289
345
  logger.debug(f"indexed row {search_index_row}")
290
346
  await session.commit()
@@ -293,8 +349,10 @@ class SearchRepository:
293
349
  """Delete an item from the search index by entity_id."""
294
350
  async with db.scoped_session(self.session_maker) as session:
295
351
  await session.execute(
296
- text("DELETE FROM search_index WHERE entity_id = :entity_id"),
297
- {"entity_id": entity_id},
352
+ text(
353
+ "DELETE FROM search_index WHERE entity_id = :entity_id AND project_id = :project_id"
354
+ ),
355
+ {"entity_id": entity_id, "project_id": self.project_id},
298
356
  )
299
357
  await session.commit()
300
358
 
@@ -302,8 +360,10 @@ class SearchRepository:
302
360
  """Delete an item from the search index."""
303
361
  async with db.scoped_session(self.session_maker) as session:
304
362
  await session.execute(
305
- text("DELETE FROM search_index WHERE permalink = :permalink"),
306
- {"permalink": permalink},
363
+ text(
364
+ "DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
365
+ ),
366
+ {"permalink": permalink, "project_id": self.project_id},
307
367
  )
308
368
  await session.commit()
309
369
 
@@ -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
  ]
@@ -0,0 +1,30 @@
1
+ """Schemas for directory tree operations."""
2
+
3
+ from datetime import datetime
4
+ from typing import List, Optional, Literal
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class DirectoryNode(BaseModel):
10
+ """Directory node in file system."""
11
+
12
+ name: str
13
+ file_path: Optional[str] = None # Original path without leading slash (matches DB)
14
+ directory_path: str # Path with leading slash for directory navigation
15
+ type: Literal["directory", "file"]
16
+ children: List["DirectoryNode"] = [] # Default to empty list
17
+ title: Optional[str] = None
18
+ permalink: Optional[str] = None
19
+ entity_id: Optional[int] = None
20
+ entity_type: Optional[str] = None
21
+ content_type: Optional[str] = None
22
+ updated_at: Optional[datetime] = None
23
+
24
+ @property
25
+ def has_children(self) -> bool:
26
+ return bool(self.children)
27
+
28
+
29
+ # Support for recursive model
30
+ DirectoryNode.model_rebuild()
@@ -0,0 +1,34 @@
1
+ """Schemas for import services."""
2
+
3
+ from typing import Dict, Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class ImportResult(BaseModel):
9
+ """Common import result schema."""
10
+
11
+ import_count: Dict[str, int]
12
+ success: bool
13
+ error_message: Optional[str] = None
14
+
15
+
16
+ class ChatImportResult(ImportResult):
17
+ """Result schema for chat imports."""
18
+
19
+ conversations: int = 0
20
+ messages: int = 0
21
+
22
+
23
+ class ProjectImportResult(ImportResult):
24
+ """Result schema for project imports."""
25
+
26
+ documents: int = 0
27
+ prompts: int = 0
28
+
29
+
30
+ class EntityImportResult(ImportResult):
31
+ """Result schema for entity imports."""
32
+
33
+ entities: int = 0
34
+ relations: int = 0