basic-memory 0.7.0__py3-none-any.whl → 0.17.4__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 (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,9 +1,11 @@
1
1
  """Repository for managing Relation objects."""
2
2
 
3
- from sqlalchemy import and_, delete
4
3
  from typing import Sequence, List, Optional
5
4
 
6
- from sqlalchemy import select
5
+
6
+ from sqlalchemy import and_, delete, select
7
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
8
+ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
7
9
  from sqlalchemy.ext.asyncio import async_sessionmaker
8
10
  from sqlalchemy.orm import selectinload, aliased
9
11
  from sqlalchemy.orm.interfaces import LoaderOption
@@ -16,8 +18,14 @@ from basic_memory.repository.repository import Repository
16
18
  class RelationRepository(Repository[Relation]):
17
19
  """Repository for Relation model with memory-specific operations."""
18
20
 
19
- def __init__(self, session_maker: async_sessionmaker):
20
- super().__init__(session_maker, Relation)
21
+ def __init__(self, session_maker: async_sessionmaker, project_id: int):
22
+ """Initialize with session maker and project_id filter.
23
+
24
+ Args:
25
+ session_maker: SQLAlchemy session maker
26
+ project_id: Project ID to filter all operations by
27
+ """
28
+ super().__init__(session_maker, Relation, project_id=project_id)
21
29
 
22
30
  async def find_relation(
23
31
  self, from_permalink: str, to_permalink: str, relation_type: str
@@ -67,5 +75,72 @@ class RelationRepository(Repository[Relation]):
67
75
  result = await self.execute_query(query)
68
76
  return result.scalars().all()
69
77
 
78
+ async def find_unresolved_relations_for_entity(self, entity_id: int) -> Sequence[Relation]:
79
+ """Find unresolved relations for a specific entity.
80
+
81
+ Args:
82
+ entity_id: The entity whose unresolved outgoing relations to find.
83
+
84
+ Returns:
85
+ List of unresolved relations where this entity is the source.
86
+ """
87
+ query = select(Relation).filter(Relation.from_id == entity_id, Relation.to_id.is_(None))
88
+ result = await self.execute_query(query)
89
+ return result.scalars().all()
90
+
91
+ async def add_all_ignore_duplicates(self, relations: List[Relation]) -> int:
92
+ """Bulk insert relations, ignoring duplicates.
93
+
94
+ Uses ON CONFLICT DO NOTHING to skip relations that would violate the
95
+ unique constraint on (from_id, to_name, relation_type). This is useful
96
+ for bulk operations where the same link may appear multiple times in
97
+ a document.
98
+
99
+ Works with both SQLite and PostgreSQL dialects.
100
+
101
+ Args:
102
+ relations: List of Relation objects to insert
103
+
104
+ Returns:
105
+ Number of relations actually inserted (excludes duplicates)
106
+ """
107
+ if not relations:
108
+ return 0
109
+
110
+ # Convert Relation objects to dicts for insert
111
+ values = [
112
+ {
113
+ "project_id": r.project_id if r.project_id else self.project_id,
114
+ "from_id": r.from_id,
115
+ "to_id": r.to_id,
116
+ "to_name": r.to_name,
117
+ "relation_type": r.relation_type,
118
+ "context": r.context,
119
+ }
120
+ for r in relations
121
+ ]
122
+
123
+ async with db.scoped_session(self.session_maker) as session:
124
+ # Check dialect to use appropriate insert
125
+ dialect_name = session.bind.dialect.name if session.bind else "sqlite"
126
+
127
+ if dialect_name == "postgresql": # pragma: no cover
128
+ # PostgreSQL: use RETURNING to count inserted rows
129
+ # (rowcount is 0 for ON CONFLICT DO NOTHING)
130
+ stmt = ( # pragma: no cover
131
+ pg_insert(Relation)
132
+ .values(values)
133
+ .on_conflict_do_nothing()
134
+ .returning(Relation.id)
135
+ )
136
+ result = await session.execute(stmt) # pragma: no cover
137
+ return len(result.fetchall()) # pragma: no cover
138
+ else:
139
+ # SQLite: rowcount works correctly
140
+ stmt = sqlite_insert(Relation).values(values)
141
+ stmt = stmt.on_conflict_do_nothing()
142
+ result = await session.execute(stmt)
143
+ return result.rowcount if result.rowcount > 0 else 0
144
+
70
145
  def get_load_options(self) -> List[LoaderOption]:
71
146
  return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]
@@ -1,6 +1,7 @@
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
6
  from loguru import logger
6
7
  from sqlalchemy import (
@@ -10,13 +11,13 @@ from sqlalchemy import (
10
11
  Executable,
11
12
  inspect,
12
13
  Result,
13
- Column,
14
14
  and_,
15
15
  delete,
16
16
  )
17
17
  from sqlalchemy.exc import NoResultFound
18
18
  from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
19
19
  from sqlalchemy.orm.interfaces import LoaderOption
20
+ from sqlalchemy.sql.elements import ColumnElement
20
21
 
21
22
  from basic_memory import db
22
23
  from basic_memory.models import Base
@@ -27,12 +28,30 @@ T = TypeVar("T", bound=Base)
27
28
  class Repository[T: Base]:
28
29
  """Base repository implementation with generic CRUD operations."""
29
30
 
30
- def __init__(self, session_maker: async_sessionmaker[AsyncSession], Model: Type[T]):
31
+ def __init__(
32
+ self,
33
+ session_maker: async_sessionmaker[AsyncSession],
34
+ Model: Type[T],
35
+ project_id: Optional[int] = None,
36
+ ):
31
37
  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]
38
+ self.project_id = project_id
39
+ if Model:
40
+ self.Model = Model
41
+ self.mapper = inspect(self.Model).mapper
42
+ self.primary_key: ColumnElement[Any] = self.mapper.primary_key[0]
43
+ self.valid_columns = [column.key for column in self.mapper.columns]
44
+ # Check if this model has a project_id column
45
+ self.has_project_id = "project_id" in self.valid_columns
46
+
47
+ def _set_project_id_if_needed(self, model: T) -> None:
48
+ """Set project_id on model if needed and available."""
49
+ if (
50
+ self.has_project_id
51
+ and self.project_id is not None
52
+ and getattr(model, "project_id", None) is None
53
+ ):
54
+ setattr(model, "project_id", self.project_id)
36
55
 
37
56
  def get_model_data(self, entity_data):
38
57
  model_data = {
@@ -40,6 +59,19 @@ class Repository[T: Base]:
40
59
  }
41
60
  return model_data
42
61
 
62
+ def _add_project_filter(self, query: Select) -> Select:
63
+ """Add project_id filter to query if applicable.
64
+
65
+ Args:
66
+ query: The SQLAlchemy query to modify
67
+
68
+ Returns:
69
+ Updated query with project filter if applicable
70
+ """
71
+ if self.has_project_id and self.project_id is not None:
72
+ query = query.filter(getattr(self.Model, "project_id") == self.project_id)
73
+ return query
74
+
43
75
  async def select_by_id(self, session: AsyncSession, entity_id: int) -> Optional[T]:
44
76
  """Select an entity by ID using an existing session."""
45
77
  query = (
@@ -47,6 +79,9 @@ class Repository[T: Base]:
47
79
  .filter(self.primary_key == entity_id)
48
80
  .options(*self.get_load_options())
49
81
  )
82
+ # Add project filter if applicable
83
+ query = self._add_project_filter(query)
84
+
50
85
  result = await session.execute(query)
51
86
  return result.scalars().one_or_none()
52
87
 
@@ -55,6 +90,9 @@ class Repository[T: Base]:
55
90
  query = (
56
91
  select(self.Model).where(self.primary_key.in_(ids)).options(*self.get_load_options())
57
92
  )
93
+ # Add project filter if applicable
94
+ query = self._add_project_filter(query)
95
+
58
96
  result = await session.execute(query)
59
97
  return result.scalars().all()
60
98
 
@@ -65,12 +103,23 @@ class Repository[T: Base]:
65
103
  :return: the added model instance
66
104
  """
67
105
  async with db.scoped_session(self.session_maker) as session:
106
+ # Set project_id if applicable and not already set
107
+ self._set_project_id_if_needed(model)
108
+
68
109
  session.add(model)
69
110
  await session.flush()
70
111
 
71
112
  # Query within same session
72
113
  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"
114
+ if found is None: # pragma: no cover
115
+ logger.error(
116
+ "Failed to retrieve model after add",
117
+ model_type=self.Model.__name__,
118
+ model_id=model.id, # pyright: ignore
119
+ )
120
+ raise ValueError(
121
+ f"Can't find {self.Model.__name__} with ID {model.id} after session.add" # pyright: ignore
122
+ )
74
123
  return found
75
124
 
76
125
  async def add_all(self, models: List[T]) -> Sequence[T]:
@@ -80,6 +129,10 @@ class Repository[T: Base]:
80
129
  :return: the added models instances
81
130
  """
82
131
  async with db.scoped_session(self.session_maker) as session:
132
+ # set the project id if not present in models
133
+ for model in models:
134
+ self._set_project_id_if_needed(model)
135
+
83
136
  session.add_all(models)
84
137
  await session.flush()
85
138
 
@@ -95,14 +148,33 @@ class Repository[T: Base]:
95
148
  """
96
149
  if not entities:
97
150
  entities = (self.Model,)
98
- return select(*entities)
151
+ query = select(*entities)
152
+
153
+ # Add project filter if applicable
154
+ return self._add_project_filter(query)
155
+
156
+ async def find_all(
157
+ self, skip: int = 0, limit: Optional[int] = None, use_load_options: bool = True
158
+ ) -> Sequence[T]:
159
+ """Fetch records from the database with pagination.
99
160
 
100
- async def find_all(self, skip: int = 0, limit: Optional[int] = 0) -> Sequence[T]:
101
- """Fetch records from the database with pagination."""
161
+ Args:
162
+ skip: Number of records to skip
163
+ limit: Maximum number of records to return
164
+ use_load_options: Whether to apply eager loading options (default: True)
165
+ """
102
166
  logger.debug(f"Finding all {self.Model.__name__} (skip={skip}, limit={limit})")
103
167
 
104
168
  async with db.scoped_session(self.session_maker) as session:
105
- query = select(self.Model).offset(skip).options(*self.get_load_options())
169
+ query = select(self.Model).offset(skip)
170
+
171
+ # Only apply load options if requested
172
+ if use_load_options:
173
+ query = query.options(*self.get_load_options())
174
+
175
+ # Add project filter if applicable
176
+ query = self._add_project_filter(query)
177
+
106
178
  if limit:
107
179
  query = query.limit(limit)
108
180
 
@@ -128,17 +200,15 @@ class Repository[T: Base]:
128
200
 
129
201
  async def find_one(self, query: Select[tuple[T]]) -> Optional[T]:
130
202
  """Execute a query and retrieve a single record."""
131
- logger.debug(f"Finding one {self.Model.__name__} with query: {query}")
132
-
133
203
  # add in load options
134
204
  query = query.options(*self.get_load_options())
135
205
  result = await self.execute_query(query)
136
206
  entity = result.scalars().one_or_none()
137
207
 
138
208
  if entity:
139
- logger.debug(f"Found {self.Model.__name__}: {getattr(entity, 'id', None)}")
209
+ logger.trace(f"Found {self.Model.__name__}: {getattr(entity, 'id', None)}")
140
210
  else:
141
- logger.debug(f"No {self.Model.__name__} found")
211
+ logger.trace(f"No {self.Model.__name__} found")
142
212
  return entity
143
213
 
144
214
  async def create(self, data: dict) -> T:
@@ -147,12 +217,29 @@ class Repository[T: Base]:
147
217
  async with db.scoped_session(self.session_maker) as session:
148
218
  # Only include valid columns that are provided in entity_data
149
219
  model_data = self.get_model_data(data)
220
+
221
+ # Add project_id if applicable and not already provided
222
+ if (
223
+ self.has_project_id
224
+ and self.project_id is not None
225
+ and "project_id" not in model_data
226
+ ):
227
+ model_data["project_id"] = self.project_id
228
+
150
229
  model = self.Model(**model_data)
151
230
  session.add(model)
152
231
  await session.flush()
153
232
 
154
233
  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"
234
+ if return_instance is None: # pragma: no cover
235
+ logger.error(
236
+ "Failed to retrieve model after create",
237
+ model_type=self.Model.__name__,
238
+ model_id=model.id, # pyright: ignore
239
+ )
240
+ raise ValueError(
241
+ f"Can't find {self.Model.__name__} with ID {model.id} after session.add" # pyright: ignore
242
+ )
156
243
  return return_instance
157
244
 
158
245
  async def create_all(self, data_list: List[dict]) -> Sequence[T]:
@@ -161,12 +248,20 @@ class Repository[T: Base]:
161
248
 
162
249
  async with db.scoped_session(self.session_maker) as session:
163
250
  # 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
- ]
251
+ model_list = []
252
+ for d in data_list:
253
+ model_data = self.get_model_data(d)
254
+
255
+ # Add project_id if applicable and not already provided
256
+ if (
257
+ self.has_project_id
258
+ and self.project_id is not None
259
+ and "project_id" not in model_data
260
+ ):
261
+ model_data["project_id"] = self.project_id # pragma: no cover
262
+
263
+ model_list.append(self.Model(**model_data))
264
+
170
265
  session.add_all(model_list)
171
266
  await session.flush()
172
267
 
@@ -222,7 +317,13 @@ class Repository[T: Base]:
222
317
  """Delete records matching given IDs."""
223
318
  logger.debug(f"Deleting {self.Model.__name__} by ids: {ids}")
224
319
  async with db.scoped_session(self.session_maker) as session:
225
- query = delete(self.Model).where(self.primary_key.in_(ids))
320
+ conditions = [self.primary_key.in_(ids)]
321
+
322
+ # Add project_id filter if applicable
323
+ if self.has_project_id and self.project_id is not None: # pragma: no cover
324
+ conditions.append(getattr(self.Model, "project_id") == self.project_id)
325
+
326
+ query = delete(self.Model).where(and_(*conditions))
226
327
  result = await session.execute(query)
227
328
  logger.debug(f"Deleted {result.rowcount} records")
228
329
  return result.rowcount
@@ -232,6 +333,11 @@ class Repository[T: Base]:
232
333
  logger.debug(f"Deleting {self.Model.__name__} by fields: {filters}")
233
334
  async with db.scoped_session(self.session_maker) as session:
234
335
  conditions = [getattr(self.Model, field) == value for field, value in filters.items()]
336
+
337
+ # Add project_id filter if applicable
338
+ if self.has_project_id and self.project_id is not None:
339
+ conditions.append(getattr(self.Model, "project_id") == self.project_id)
340
+
235
341
  query = delete(self.Model).where(and_(*conditions))
236
342
  result = await session.execute(query)
237
343
  deleted = result.rowcount > 0
@@ -243,21 +349,34 @@ class Repository[T: Base]:
243
349
  async with db.scoped_session(self.session_maker) as session:
244
350
  if query is None:
245
351
  query = select(func.count()).select_from(self.Model)
352
+ # Add project filter if applicable
353
+ if (
354
+ isinstance(query, Select)
355
+ and self.has_project_id
356
+ and self.project_id is not None
357
+ ):
358
+ query = query.where(
359
+ getattr(self.Model, "project_id") == self.project_id
360
+ ) # pragma: no cover
361
+
246
362
  result = await session.execute(query)
247
363
  scalar = result.scalar()
248
364
  count = scalar if scalar is not None else 0
249
365
  logger.debug(f"Counted {count} {self.Model.__name__} records")
250
366
  return count
251
367
 
252
- async def execute_query(self, query: Executable, use_query_options: bool = True) -> Result[Any]:
368
+ async def execute_query(
369
+ self,
370
+ query: Executable,
371
+ params: Optional[Dict[str, Any]] = None,
372
+ use_query_options: bool = True,
373
+ ) -> Result[Any]:
253
374
  """Execute a query asynchronously."""
254
375
 
255
376
  query = query.options(*self.get_load_options()) if use_query_options else query
256
-
257
- logger.debug(f"Executing query: {query}")
377
+ logger.trace(f"Executing query: {query}, params: {params}")
258
378
  async with db.scoped_session(self.session_maker) as session:
259
- result = await session.execute(query)
260
- logger.debug("Query executed successfully")
379
+ result = await session.execute(query, params)
261
380
  return result
262
381
 
263
382
  def get_load_options(self) -> List[LoaderOption]:
@@ -0,0 +1,95 @@
1
+ """Search index data structures."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from typing import Optional
7
+ from pathlib import Path
8
+
9
+ from basic_memory.schemas.search import SearchItemType
10
+
11
+
12
+ @dataclass
13
+ class SearchIndexRow:
14
+ """Search result with score and metadata."""
15
+
16
+ project_id: int
17
+ id: int
18
+ type: str
19
+ file_path: str
20
+
21
+ # date values
22
+ created_at: datetime
23
+ updated_at: datetime
24
+
25
+ permalink: Optional[str] = None
26
+ metadata: Optional[dict] = None
27
+
28
+ # assigned in result
29
+ score: Optional[float] = None
30
+
31
+ # Type-specific fields
32
+ title: Optional[str] = None # entity
33
+ content_stems: Optional[str] = None # entity, observation
34
+ content_snippet: Optional[str] = None # entity, observation
35
+ entity_id: Optional[int] = None # observations
36
+ category: Optional[str] = None # observations
37
+ from_id: Optional[int] = None # relations
38
+ to_id: Optional[int] = None # relations
39
+ relation_type: Optional[str] = None # relations
40
+
41
+ @property
42
+ def content(self):
43
+ return self.content_snippet
44
+
45
+ @property
46
+ def directory(self) -> str:
47
+ """Extract directory part from file_path.
48
+
49
+ For a file at "projects/notes/ideas.md", returns "/projects/notes"
50
+ For a file at root level "README.md", returns "/"
51
+ """
52
+ if not self.type == SearchItemType.ENTITY.value and not self.file_path:
53
+ return ""
54
+
55
+ # Normalize path separators to handle both Windows (\) and Unix (/) paths
56
+ normalized_path = Path(self.file_path).as_posix()
57
+
58
+ # Split the path by slashes
59
+ parts = normalized_path.split("/")
60
+
61
+ # If there's only one part (e.g., "README.md"), it's at the root
62
+ if len(parts) <= 1:
63
+ return "/"
64
+
65
+ # Join all parts except the last one (filename)
66
+ directory_path = "/".join(parts[:-1])
67
+ return f"/{directory_path}"
68
+
69
+ def to_insert(self, serialize_json: bool = True):
70
+ """Convert to dict for database insertion.
71
+
72
+ Args:
73
+ serialize_json: If True, converts metadata dict to JSON string (for SQLite).
74
+ If False, keeps metadata as dict (for Postgres JSONB).
75
+ """
76
+ return {
77
+ "id": self.id,
78
+ "title": self.title,
79
+ "content_stems": self.content_stems,
80
+ "content_snippet": self.content_snippet,
81
+ "permalink": self.permalink,
82
+ "file_path": self.file_path,
83
+ "type": self.type,
84
+ "metadata": json.dumps(self.metadata)
85
+ if serialize_json and self.metadata
86
+ else self.metadata,
87
+ "from_id": self.from_id,
88
+ "to_id": self.to_id,
89
+ "relation_type": self.relation_type,
90
+ "entity_id": self.entity_id,
91
+ "category": self.category,
92
+ "created_at": self.created_at if self.created_at else None,
93
+ "updated_at": self.updated_at if self.updated_at else None,
94
+ "project_id": self.project_id,
95
+ }