basic-memory 0.2.12__py3-none-any.whl → 0.16.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (149) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +63 -31
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +165 -28
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +28 -67
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +219 -14
  23. basic_memory/api/routers/search_router.py +21 -13
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +52 -1
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +26 -7
  41. basic_memory/cli/commands/import_chatgpt.py +83 -0
  42. basic_memory/cli/commands/import_claude_conversations.py +86 -0
  43. basic_memory/cli/commands/import_claude_projects.py +85 -0
  44. basic_memory/cli/commands/import_memory_json.py +35 -92
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +47 -30
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +13 -6
  50. basic_memory/config.py +481 -22
  51. basic_memory/db.py +192 -32
  52. basic_memory/deps.py +252 -22
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -14
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +437 -59
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +188 -23
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +57 -3
  115. basic_memory/schemas/response.py +9 -1
  116. basic_memory/schemas/search.py +33 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +251 -106
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +595 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +50 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +172 -34
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1176 -96
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +388 -28
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -203
  140. basic_memory/mcp/tools/knowledge.py +0 -56
  141. basic_memory/mcp/tools/memory.py +0 -151
  142. basic_memory/mcp/tools/notes.py +0 -122
  143. basic_memory/schemas/discovery.py +0 -28
  144. basic_memory/sync/file_change_scanner.py +0 -158
  145. basic_memory/sync/utils.py +0 -34
  146. basic_memory-0.2.12.dist-info/METADATA +0 -291
  147. basic_memory-0.2.12.dist-info/RECORD +0 -78
  148. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  149. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.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 (
@@ -10,13 +10,13 @@ from sqlalchemy import (
10
10
  Executable,
11
11
  inspect,
12
12
  Result,
13
- Column,
14
13
  and_,
15
14
  delete,
16
15
  )
17
16
  from sqlalchemy.exc import NoResultFound
18
17
  from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
19
18
  from sqlalchemy.orm.interfaces import LoaderOption
19
+ from sqlalchemy.sql.elements import ColumnElement
20
20
 
21
21
  from basic_memory import db
22
22
  from basic_memory.models import Base
@@ -27,12 +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
32
- self.Model = Model
33
- self.mapper = inspect(self.Model).mapper
34
- self.primary_key: Column[Any] = self.mapper.primary_key[0]
35
- self.valid_columns = [column.key for column in self.mapper.columns]
37
+ self.project_id = project_id
38
+ if Model:
39
+ self.Model = Model
40
+ self.mapper = inspect(self.Model).mapper
41
+ self.primary_key: ColumnElement[Any] = self.mapper.primary_key[0]
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)
36
54
 
37
55
  def get_model_data(self, entity_data):
38
56
  model_data = {
@@ -40,6 +58,19 @@ class Repository[T: Base]:
40
58
  }
41
59
  return model_data
42
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
+
43
74
  async def select_by_id(self, session: AsyncSession, entity_id: int) -> Optional[T]:
44
75
  """Select an entity by ID using an existing session."""
45
76
  query = (
@@ -47,6 +78,9 @@ class Repository[T: Base]:
47
78
  .filter(self.primary_key == entity_id)
48
79
  .options(*self.get_load_options())
49
80
  )
81
+ # Add project filter if applicable
82
+ query = self._add_project_filter(query)
83
+
50
84
  result = await session.execute(query)
51
85
  return result.scalars().one_or_none()
52
86
 
@@ -55,6 +89,9 @@ class Repository[T: Base]:
55
89
  query = (
56
90
  select(self.Model).where(self.primary_key.in_(ids)).options(*self.get_load_options())
57
91
  )
92
+ # Add project filter if applicable
93
+ query = self._add_project_filter(query)
94
+
58
95
  result = await session.execute(query)
59
96
  return result.scalars().all()
60
97
 
@@ -65,12 +102,23 @@ class Repository[T: Base]:
65
102
  :return: the added model instance
66
103
  """
67
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
+
68
108
  session.add(model)
69
109
  await session.flush()
70
110
 
71
111
  # Query within same session
72
112
  found = await self.select_by_id(session, model.id) # pyright: ignore [reportAttributeAccessIssue]
73
- assert found is not None, "can't find model after session.add"
113
+ if found is None: # pragma: no cover
114
+ logger.error(
115
+ "Failed to retrieve model after add",
116
+ model_type=self.Model.__name__,
117
+ model_id=model.id, # pyright: ignore
118
+ )
119
+ raise ValueError(
120
+ f"Can't find {self.Model.__name__} with ID {model.id} after session.add" # pyright: ignore
121
+ )
74
122
  return found
75
123
 
76
124
  async def add_all(self, models: List[T]) -> Sequence[T]:
@@ -80,6 +128,10 @@ class Repository[T: Base]:
80
128
  :return: the added models instances
81
129
  """
82
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
+
83
135
  session.add_all(models)
84
136
  await session.flush()
85
137
 
@@ -95,14 +147,33 @@ class Repository[T: Base]:
95
147
  """
96
148
  if not entities:
97
149
  entities = (self.Model,)
98
- return select(*entities)
150
+ query = select(*entities)
151
+
152
+ # Add project filter if applicable
153
+ return self._add_project_filter(query)
154
+
155
+ async def find_all(
156
+ self, skip: int = 0, limit: Optional[int] = None, use_load_options: bool = True
157
+ ) -> Sequence[T]:
158
+ """Fetch records from the database with pagination.
99
159
 
100
- async def find_all(self, skip: int = 0, limit: Optional[int] = 0) -> Sequence[T]:
101
- """Fetch records from the database with pagination."""
160
+ Args:
161
+ skip: Number of records to skip
162
+ limit: Maximum number of records to return
163
+ use_load_options: Whether to apply eager loading options (default: True)
164
+ """
102
165
  logger.debug(f"Finding all {self.Model.__name__} (skip={skip}, limit={limit})")
103
166
 
104
167
  async with db.scoped_session(self.session_maker) as session:
105
- query = select(self.Model).offset(skip).options(*self.get_load_options())
168
+ query = select(self.Model).offset(skip)
169
+
170
+ # Only apply load options if requested
171
+ if use_load_options:
172
+ query = query.options(*self.get_load_options())
173
+
174
+ # Add project filter if applicable
175
+ query = self._add_project_filter(query)
176
+
106
177
  if limit:
107
178
  query = query.limit(limit)
108
179
 
@@ -128,17 +199,15 @@ class Repository[T: Base]:
128
199
 
129
200
  async def find_one(self, query: Select[tuple[T]]) -> Optional[T]:
130
201
  """Execute a query and retrieve a single record."""
131
- logger.debug(f"Finding one {self.Model.__name__} with query: {query}")
132
-
133
202
  # add in load options
134
203
  query = query.options(*self.get_load_options())
135
204
  result = await self.execute_query(query)
136
205
  entity = result.scalars().one_or_none()
137
206
 
138
207
  if entity:
139
- logger.debug(f"Found {self.Model.__name__}: {getattr(entity, 'id', None)}")
208
+ logger.trace(f"Found {self.Model.__name__}: {getattr(entity, 'id', None)}")
140
209
  else:
141
- logger.debug(f"No {self.Model.__name__} found")
210
+ logger.trace(f"No {self.Model.__name__} found")
142
211
  return entity
143
212
 
144
213
  async def create(self, data: dict) -> T:
@@ -147,12 +216,29 @@ class Repository[T: Base]:
147
216
  async with db.scoped_session(self.session_maker) as session:
148
217
  # Only include valid columns that are provided in entity_data
149
218
  model_data = self.get_model_data(data)
219
+
220
+ # Add project_id if applicable and not already provided
221
+ if (
222
+ self.has_project_id
223
+ and self.project_id is not None
224
+ and "project_id" not in model_data
225
+ ):
226
+ model_data["project_id"] = self.project_id
227
+
150
228
  model = self.Model(**model_data)
151
229
  session.add(model)
152
230
  await session.flush()
153
231
 
154
232
  return_instance = await self.select_by_id(session, model.id) # pyright: ignore [reportAttributeAccessIssue]
155
- assert return_instance is not None, "can't find model after session.add"
233
+ if return_instance is None: # pragma: no cover
234
+ logger.error(
235
+ "Failed to retrieve model after create",
236
+ model_type=self.Model.__name__,
237
+ model_id=model.id, # pyright: ignore
238
+ )
239
+ raise ValueError(
240
+ f"Can't find {self.Model.__name__} with ID {model.id} after session.add" # pyright: ignore
241
+ )
156
242
  return return_instance
157
243
 
158
244
  async def create_all(self, data_list: List[dict]) -> Sequence[T]:
@@ -161,12 +247,20 @@ class Repository[T: Base]:
161
247
 
162
248
  async with db.scoped_session(self.session_maker) as session:
163
249
  # Only include valid columns that are provided in entity_data
164
- model_list = [
165
- self.Model(
166
- **self.get_model_data(d),
167
- )
168
- for d in data_list
169
- ]
250
+ model_list = []
251
+ for d in data_list:
252
+ model_data = self.get_model_data(d)
253
+
254
+ # Add project_id if applicable and not already provided
255
+ if (
256
+ self.has_project_id
257
+ and self.project_id is not None
258
+ and "project_id" not in model_data
259
+ ):
260
+ model_data["project_id"] = self.project_id # pragma: no cover
261
+
262
+ model_list.append(self.Model(**model_data))
263
+
170
264
  session.add_all(model_list)
171
265
  await session.flush()
172
266
 
@@ -222,7 +316,13 @@ class Repository[T: Base]:
222
316
  """Delete records matching given IDs."""
223
317
  logger.debug(f"Deleting {self.Model.__name__} by ids: {ids}")
224
318
  async with db.scoped_session(self.session_maker) as session:
225
- query = delete(self.Model).where(self.primary_key.in_(ids))
319
+ conditions = [self.primary_key.in_(ids)]
320
+
321
+ # Add project_id filter if applicable
322
+ if self.has_project_id and self.project_id is not None: # pragma: no cover
323
+ conditions.append(getattr(self.Model, "project_id") == self.project_id)
324
+
325
+ query = delete(self.Model).where(and_(*conditions))
226
326
  result = await session.execute(query)
227
327
  logger.debug(f"Deleted {result.rowcount} records")
228
328
  return result.rowcount
@@ -232,6 +332,11 @@ class Repository[T: Base]:
232
332
  logger.debug(f"Deleting {self.Model.__name__} by fields: {filters}")
233
333
  async with db.scoped_session(self.session_maker) as session:
234
334
  conditions = [getattr(self.Model, field) == value for field, value in filters.items()]
335
+
336
+ # Add project_id filter if applicable
337
+ if self.has_project_id and self.project_id is not None:
338
+ conditions.append(getattr(self.Model, "project_id") == self.project_id)
339
+
235
340
  query = delete(self.Model).where(and_(*conditions))
236
341
  result = await session.execute(query)
237
342
  deleted = result.rowcount > 0
@@ -243,21 +348,34 @@ class Repository[T: Base]:
243
348
  async with db.scoped_session(self.session_maker) as session:
244
349
  if query is None:
245
350
  query = select(func.count()).select_from(self.Model)
351
+ # Add project filter if applicable
352
+ if (
353
+ isinstance(query, Select)
354
+ and self.has_project_id
355
+ and self.project_id is not None
356
+ ):
357
+ query = query.where(
358
+ getattr(self.Model, "project_id") == self.project_id
359
+ ) # pragma: no cover
360
+
246
361
  result = await session.execute(query)
247
362
  scalar = result.scalar()
248
363
  count = scalar if scalar is not None else 0
249
364
  logger.debug(f"Counted {count} {self.Model.__name__} records")
250
365
  return count
251
366
 
252
- async def execute_query(self, query: Executable, use_query_options: bool = True) -> Result[Any]:
367
+ async def execute_query(
368
+ self,
369
+ query: Executable,
370
+ params: Optional[Dict[str, Any]] = None,
371
+ use_query_options: bool = True,
372
+ ) -> Result[Any]:
253
373
  """Execute a query asynchronously."""
254
374
 
255
375
  query = query.options(*self.get_load_options()) if use_query_options else query
256
-
257
- logger.debug(f"Executing query: {query}")
376
+ logger.trace(f"Executing query: {query}, params: {params}")
258
377
  async with db.scoped_session(self.session_maker) as session:
259
- result = await session.execute(query)
260
- logger.debug("Query executed successfully")
378
+ result = await session.execute(query, params)
261
379
  return result
262
380
 
263
381
  def get_load_options(self) -> List[LoaderOption]: