basic-memory 0.17.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.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  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 +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  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 +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -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/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,404 @@
1
+ """Service for search operations."""
2
+
3
+ import ast
4
+ from datetime import datetime
5
+ from typing import List, Optional, Set
6
+
7
+
8
+ from dateparser import parse
9
+ from fastapi import BackgroundTasks
10
+ from loguru import logger
11
+ from sqlalchemy import text
12
+
13
+ from basic_memory.models import Entity
14
+ from basic_memory.repository import EntityRepository
15
+ from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
16
+ from basic_memory.schemas.search import SearchQuery, SearchItemType
17
+ from basic_memory.services import FileService
18
+
19
+ # Maximum size for content_stems field to stay under Postgres's 8KB index row limit.
20
+ # We use 6000 characters to leave headroom for other indexed columns and overhead.
21
+ MAX_CONTENT_STEMS_SIZE = 6000
22
+
23
+
24
+ def _mtime_to_datetime(entity: Entity) -> datetime:
25
+ """Convert entity mtime (file modification time) to datetime.
26
+
27
+ Returns the file's actual modification time, falling back to updated_at
28
+ if mtime is not available.
29
+ """
30
+ if entity.mtime:
31
+ return datetime.fromtimestamp(entity.mtime).astimezone()
32
+ return entity.updated_at
33
+
34
+
35
+ class SearchService:
36
+ """Service for search operations.
37
+
38
+ Supports three primary search modes:
39
+ 1. Exact permalink lookup
40
+ 2. Pattern matching with * (e.g., 'specs/*')
41
+ 3. Full-text search across title/content
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ search_repository: SearchRepository,
47
+ entity_repository: EntityRepository,
48
+ file_service: FileService,
49
+ ):
50
+ self.repository = search_repository
51
+ self.entity_repository = entity_repository
52
+ self.file_service = file_service
53
+
54
+ async def init_search_index(self):
55
+ """Create FTS5 virtual table if it doesn't exist."""
56
+ await self.repository.init_search_index()
57
+
58
+ async def reindex_all(self, background_tasks: Optional[BackgroundTasks] = None) -> None:
59
+ """Reindex all content from database."""
60
+
61
+ logger.info("Starting full reindex")
62
+ # Clear and recreate search index
63
+ await self.repository.execute_query(text("DROP TABLE IF EXISTS search_index"), params={})
64
+ await self.init_search_index()
65
+
66
+ # Reindex all entities
67
+ logger.debug("Indexing entities")
68
+ entities = await self.entity_repository.find_all()
69
+ for entity in entities:
70
+ await self.index_entity(entity, background_tasks)
71
+
72
+ logger.info("Reindex complete")
73
+
74
+ async def search(self, query: SearchQuery, limit=10, offset=0) -> List[SearchIndexRow]:
75
+ """Search across all indexed content.
76
+
77
+ Supports three modes:
78
+ 1. Exact permalink: finds direct matches for a specific path
79
+ 2. Pattern match: handles * wildcards in paths
80
+ 3. Text search: full-text search across title/content
81
+ """
82
+ if query.no_criteria():
83
+ logger.debug("no criteria passed to query")
84
+ return []
85
+
86
+ logger.trace(f"Searching with query: {query}")
87
+
88
+ after_date = (
89
+ (
90
+ query.after_date
91
+ if isinstance(query.after_date, datetime)
92
+ else parse(query.after_date)
93
+ )
94
+ if query.after_date
95
+ else None
96
+ )
97
+
98
+ # search
99
+ results = await self.repository.search(
100
+ search_text=query.text,
101
+ permalink=query.permalink,
102
+ permalink_match=query.permalink_match,
103
+ title=query.title,
104
+ types=query.types,
105
+ search_item_types=query.entity_types,
106
+ after_date=after_date,
107
+ limit=limit,
108
+ offset=offset,
109
+ )
110
+
111
+ return results
112
+
113
+ @staticmethod
114
+ def _generate_variants(text: str) -> Set[str]:
115
+ """Generate text variants for better fuzzy matching.
116
+
117
+ Creates variations of the text to improve match chances:
118
+ - Original form
119
+ - Lowercase form
120
+ - Path segments (for permalinks)
121
+ - Common word boundaries
122
+ """
123
+ variants = {text, text.lower()}
124
+
125
+ # Add path segments
126
+ if "/" in text:
127
+ variants.update(p.strip() for p in text.split("/") if p.strip())
128
+
129
+ # Add word boundaries
130
+ variants.update(w.strip() for w in text.lower().split() if w.strip())
131
+
132
+ # Trigrams disabled: They create massive search index bloat, increasing DB size significantly
133
+ # and slowing down indexing performance. FTS5 search works well without them.
134
+ # See: https://github.com/basicmachines-co/basic-memory/issues/351
135
+ # variants.update(text[i : i + 3].lower() for i in range(len(text) - 2))
136
+
137
+ return variants
138
+
139
+ def _extract_entity_tags(self, entity: Entity) -> List[str]:
140
+ """Extract tags from entity metadata for search indexing.
141
+
142
+ Handles multiple tag formats:
143
+ - List format: ["tag1", "tag2"]
144
+ - String format: "['tag1', 'tag2']" or "[tag1, tag2]"
145
+ - Empty: [] or "[]"
146
+
147
+ Returns a list of tag strings for search indexing.
148
+ """
149
+ if not entity.entity_metadata or "tags" not in entity.entity_metadata:
150
+ return []
151
+
152
+ tags = entity.entity_metadata["tags"]
153
+
154
+ # Handle list format (preferred)
155
+ if isinstance(tags, list):
156
+ return [str(tag) for tag in tags if tag]
157
+
158
+ # Handle string format (legacy)
159
+ if isinstance(tags, str):
160
+ try:
161
+ # Parse string representation of list
162
+ parsed_tags = ast.literal_eval(tags)
163
+ if isinstance(parsed_tags, list):
164
+ return [str(tag) for tag in parsed_tags if tag]
165
+ except (ValueError, SyntaxError):
166
+ # If parsing fails, treat as single tag
167
+ return [tags] if tags.strip() else []
168
+
169
+ return [] # pragma: no cover
170
+
171
+ async def index_entity(
172
+ self,
173
+ entity: Entity,
174
+ background_tasks: Optional[BackgroundTasks] = None,
175
+ content: str | None = None,
176
+ ) -> None:
177
+ if background_tasks:
178
+ background_tasks.add_task(self.index_entity_data, entity, content)
179
+ else:
180
+ await self.index_entity_data(entity, content)
181
+
182
+ async def index_entity_data(
183
+ self,
184
+ entity: Entity,
185
+ content: str | None = None,
186
+ ) -> None:
187
+ # delete all search index data associated with entity
188
+ await self.repository.delete_by_entity_id(entity_id=entity.id)
189
+
190
+ # reindex
191
+ await self.index_entity_markdown(
192
+ entity, content
193
+ ) if entity.is_markdown else await self.index_entity_file(entity)
194
+
195
+ async def index_entity_file(
196
+ self,
197
+ entity: Entity,
198
+ ) -> None:
199
+ # Index entity file with no content
200
+ await self.repository.index_item(
201
+ SearchIndexRow(
202
+ id=entity.id,
203
+ entity_id=entity.id,
204
+ type=SearchItemType.ENTITY.value,
205
+ title=entity.title,
206
+ permalink=entity.permalink, # Required for Postgres NOT NULL constraint
207
+ file_path=entity.file_path,
208
+ metadata={
209
+ "entity_type": entity.entity_type,
210
+ },
211
+ created_at=entity.created_at,
212
+ updated_at=_mtime_to_datetime(entity),
213
+ project_id=entity.project_id,
214
+ )
215
+ )
216
+
217
+ async def index_entity_markdown(
218
+ self,
219
+ entity: Entity,
220
+ content: str | None = None,
221
+ ) -> None:
222
+ """Index an entity and all its observations and relations.
223
+
224
+ Args:
225
+ entity: The entity to index
226
+ content: Optional pre-loaded content (avoids file read). If None, will read from file.
227
+
228
+ Indexing structure:
229
+ 1. Entities
230
+ - permalink: direct from entity (e.g., "specs/search")
231
+ - file_path: physical file location
232
+ - project_id: project context for isolation
233
+
234
+ 2. Observations
235
+ - permalink: entity permalink + /observations/id (e.g., "specs/search/observations/123")
236
+ - file_path: parent entity's file (where observation is defined)
237
+ - project_id: inherited from parent entity
238
+
239
+ 3. Relations (only index outgoing relations defined in this file)
240
+ - permalink: from_entity/relation_type/to_entity (e.g., "specs/search/implements/features/search-ui")
241
+ - file_path: source entity's file (where relation is defined)
242
+ - project_id: inherited from source entity
243
+
244
+ Each type gets its own row in the search index with appropriate metadata.
245
+ The project_id is automatically added by the repository when indexing.
246
+ """
247
+
248
+ # Collect all search index rows to batch insert at the end
249
+ rows_to_index = []
250
+
251
+ content_stems = []
252
+ content_snippet = ""
253
+ title_variants = self._generate_variants(entity.title)
254
+ content_stems.extend(title_variants)
255
+
256
+ # Use provided content or read from file
257
+ if content is None:
258
+ content = await self.file_service.read_entity_content(entity)
259
+ if content:
260
+ content_stems.append(content)
261
+ content_snippet = f"{content[:250]}"
262
+
263
+ if entity.permalink:
264
+ content_stems.extend(self._generate_variants(entity.permalink))
265
+
266
+ content_stems.extend(self._generate_variants(entity.file_path))
267
+
268
+ # Add entity tags from frontmatter to search content
269
+ entity_tags = self._extract_entity_tags(entity)
270
+ if entity_tags:
271
+ content_stems.extend(entity_tags)
272
+
273
+ entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
274
+
275
+ # Truncate to stay under Postgres's 8KB index row limit
276
+ if len(entity_content_stems) > MAX_CONTENT_STEMS_SIZE:
277
+ entity_content_stems = entity_content_stems[:MAX_CONTENT_STEMS_SIZE]
278
+
279
+ # Add entity row
280
+ rows_to_index.append(
281
+ SearchIndexRow(
282
+ id=entity.id,
283
+ type=SearchItemType.ENTITY.value,
284
+ title=entity.title,
285
+ content_stems=entity_content_stems,
286
+ content_snippet=content_snippet,
287
+ permalink=entity.permalink,
288
+ file_path=entity.file_path,
289
+ entity_id=entity.id,
290
+ metadata={
291
+ "entity_type": entity.entity_type,
292
+ },
293
+ created_at=entity.created_at,
294
+ updated_at=_mtime_to_datetime(entity),
295
+ project_id=entity.project_id,
296
+ )
297
+ )
298
+
299
+ # Add observation rows - dedupe by permalink to avoid unique constraint violations
300
+ # Two observations with same entity/category/content generate identical permalinks
301
+ seen_permalinks: set[str] = {entity.permalink} if entity.permalink else set()
302
+ for obs in entity.observations:
303
+ obs_permalink = obs.permalink
304
+ if obs_permalink in seen_permalinks:
305
+ logger.debug(f"Skipping duplicate observation permalink: {obs_permalink}")
306
+ continue
307
+ seen_permalinks.add(obs_permalink)
308
+
309
+ # Index with parent entity's file path since that's where it's defined
310
+ obs_content_stems = "\n".join(
311
+ p for p in self._generate_variants(obs.content) if p and p.strip()
312
+ )
313
+ # Truncate to stay under Postgres's 8KB index row limit
314
+ if len(obs_content_stems) > MAX_CONTENT_STEMS_SIZE:
315
+ obs_content_stems = obs_content_stems[:MAX_CONTENT_STEMS_SIZE]
316
+ rows_to_index.append(
317
+ SearchIndexRow(
318
+ id=obs.id,
319
+ type=SearchItemType.OBSERVATION.value,
320
+ title=f"{obs.category}: {obs.content[:100]}...",
321
+ content_stems=obs_content_stems,
322
+ content_snippet=obs.content,
323
+ permalink=obs_permalink,
324
+ file_path=entity.file_path,
325
+ category=obs.category,
326
+ entity_id=entity.id,
327
+ metadata={
328
+ "tags": obs.tags,
329
+ },
330
+ created_at=entity.created_at,
331
+ updated_at=_mtime_to_datetime(entity),
332
+ project_id=entity.project_id,
333
+ )
334
+ )
335
+
336
+ # Add relation rows (only outgoing relations defined in this file)
337
+ for rel in entity.outgoing_relations:
338
+ # Create descriptive title showing the relationship
339
+ relation_title = (
340
+ f"{rel.from_entity.title} → {rel.to_entity.title}"
341
+ if rel.to_entity
342
+ else f"{rel.from_entity.title}"
343
+ )
344
+
345
+ rel_content_stems = "\n".join(
346
+ p for p in self._generate_variants(relation_title) if p and p.strip()
347
+ )
348
+ rows_to_index.append(
349
+ SearchIndexRow(
350
+ id=rel.id,
351
+ title=relation_title,
352
+ permalink=rel.permalink,
353
+ content_stems=rel_content_stems,
354
+ file_path=entity.file_path,
355
+ type=SearchItemType.RELATION.value,
356
+ entity_id=entity.id,
357
+ from_id=rel.from_id,
358
+ to_id=rel.to_id,
359
+ relation_type=rel.relation_type,
360
+ created_at=entity.created_at,
361
+ updated_at=_mtime_to_datetime(entity),
362
+ project_id=entity.project_id,
363
+ )
364
+ )
365
+
366
+ # Batch insert all rows at once
367
+ await self.repository.bulk_index_items(rows_to_index)
368
+
369
+ async def delete_by_permalink(self, permalink: str):
370
+ """Delete an item from the search index."""
371
+ await self.repository.delete_by_permalink(permalink)
372
+
373
+ async def delete_by_entity_id(self, entity_id: int):
374
+ """Delete an item from the search index."""
375
+ await self.repository.delete_by_entity_id(entity_id)
376
+
377
+ async def handle_delete(self, entity: Entity):
378
+ """Handle complete entity deletion from search index including observations and relations.
379
+
380
+ This replicates the logic from sync_service.handle_delete() to properly clean up
381
+ all search index entries for an entity and its related data.
382
+ """
383
+ logger.debug(
384
+ f"Cleaning up search index for entity_id={entity.id}, file_path={entity.file_path}, "
385
+ f"observations={len(entity.observations)}, relations={len(entity.outgoing_relations)}"
386
+ )
387
+
388
+ # Clean up search index - same logic as sync_service.handle_delete()
389
+ permalinks = (
390
+ [entity.permalink]
391
+ + [o.permalink for o in entity.observations]
392
+ + [r.permalink for r in entity.outgoing_relations]
393
+ )
394
+
395
+ logger.debug(
396
+ f"Deleting search index entries for entity_id={entity.id}, "
397
+ f"index_entries={len(permalinks)}"
398
+ )
399
+
400
+ for permalink in permalinks:
401
+ if permalink:
402
+ await self.delete_by_permalink(permalink)
403
+ else:
404
+ await self.delete_by_entity_id(entity.id)
@@ -0,0 +1,15 @@
1
+ """Base service class."""
2
+
3
+ from typing import TypeVar, Generic
4
+
5
+ from basic_memory.models import Base
6
+
7
+ T = TypeVar("T", bound=Base)
8
+
9
+
10
+ class BaseService(Generic[T]):
11
+ """Base service that takes a repository."""
12
+
13
+ def __init__(self, repository):
14
+ """Initialize service with repository."""
15
+ self.repository = repository
@@ -0,0 +1,6 @@
1
+ """Basic Memory sync services."""
2
+
3
+ from .sync_service import SyncService
4
+ from .watch_service import WatchService
5
+
6
+ __all__ = ["SyncService", "WatchService"]
@@ -0,0 +1,26 @@
1
+ import asyncio
2
+
3
+ from loguru import logger
4
+
5
+ from basic_memory.config import get_project_config
6
+ from basic_memory.sync import SyncService, WatchService
7
+
8
+
9
+ async def sync_and_watch(
10
+ sync_service: SyncService, watch_service: WatchService
11
+ ): # pragma: no cover
12
+ """Run sync and watch service."""
13
+
14
+ config = get_project_config()
15
+ logger.info(f"Starting watch service to sync file changes in dir: {config.home}")
16
+ # full sync
17
+ await sync_service.sync(config.home)
18
+
19
+ # watch changes
20
+ await watch_service.run()
21
+
22
+
23
+ async def create_background_sync_task(
24
+ sync_service: SyncService, watch_service: WatchService
25
+ ): # pragma: no cover
26
+ return asyncio.create_task(sync_and_watch(sync_service, watch_service))