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,10 +1,13 @@
1
1
  """Service for search operations."""
2
2
 
3
+ import ast
3
4
  from datetime import datetime
4
5
  from typing import List, Optional, Set
5
6
 
7
+ from dateparser import parse
6
8
  from fastapi import BackgroundTasks
7
9
  from loguru import logger
10
+ from sqlalchemy import text
8
11
 
9
12
  from basic_memory.models import Entity
10
13
  from basic_memory.repository import EntityRepository
@@ -38,9 +41,10 @@ class SearchService:
38
41
 
39
42
  async def reindex_all(self, background_tasks: Optional[BackgroundTasks] = None) -> None:
40
43
  """Reindex all content from database."""
41
- logger.info("Starting full reindex")
42
44
 
45
+ logger.info("Starting full reindex")
43
46
  # Clear and recreate search index
47
+ await self.repository.execute_query(text("DROP TABLE IF EXISTS search_index"), params={})
44
48
  await self.init_search_index()
45
49
 
46
50
  # Reindex all entities
@@ -51,7 +55,7 @@ class SearchService:
51
55
 
52
56
  logger.info("Reindex complete")
53
57
 
54
- async def search(self, query: SearchQuery) -> List[SearchIndexRow]:
58
+ async def search(self, query: SearchQuery, limit=10, offset=0) -> List[SearchIndexRow]:
55
59
  """Search across all indexed content.
56
60
 
57
61
  Supports three modes:
@@ -63,27 +67,29 @@ class SearchService:
63
67
  logger.debug("no criteria passed to query")
64
68
  return []
65
69
 
66
- logger.debug(f"Searching with query: {query}")
70
+ logger.trace(f"Searching with query: {query}")
67
71
 
68
72
  after_date = (
69
73
  (
70
74
  query.after_date
71
75
  if isinstance(query.after_date, datetime)
72
- else datetime.fromisoformat(query.after_date)
76
+ else parse(query.after_date)
73
77
  )
74
78
  if query.after_date
75
79
  else None
76
80
  )
77
81
 
78
- # permalink search
82
+ # search
79
83
  results = await self.repository.search(
80
84
  search_text=query.text,
81
85
  permalink=query.permalink,
82
86
  permalink_match=query.permalink_match,
83
87
  title=query.title,
84
88
  types=query.types,
85
- entity_types=query.entity_types,
89
+ search_item_types=query.entity_types,
86
90
  after_date=after_date,
91
+ limit=limit,
92
+ offset=offset,
87
93
  )
88
94
 
89
95
  return results
@@ -107,15 +113,91 @@ class SearchService:
107
113
  # Add word boundaries
108
114
  variants.update(w.strip() for w in text.lower().split() if w.strip())
109
115
 
110
- # Add trigrams for fuzzy matching
111
- variants.update(text[i : i + 3].lower() for i in range(len(text) - 2))
116
+ # Trigrams disabled: They create massive search index bloat, increasing DB size significantly
117
+ # and slowing down indexing performance. FTS5 search works well without them.
118
+ # See: https://github.com/basicmachines-co/basic-memory/issues/351
119
+ # variants.update(text[i : i + 3].lower() for i in range(len(text) - 2))
112
120
 
113
121
  return variants
114
122
 
123
+ def _extract_entity_tags(self, entity: Entity) -> List[str]:
124
+ """Extract tags from entity metadata for search indexing.
125
+
126
+ Handles multiple tag formats:
127
+ - List format: ["tag1", "tag2"]
128
+ - String format: "['tag1', 'tag2']" or "[tag1, tag2]"
129
+ - Empty: [] or "[]"
130
+
131
+ Returns a list of tag strings for search indexing.
132
+ """
133
+ if not entity.entity_metadata or "tags" not in entity.entity_metadata:
134
+ return []
135
+
136
+ tags = entity.entity_metadata["tags"]
137
+
138
+ # Handle list format (preferred)
139
+ if isinstance(tags, list):
140
+ return [str(tag) for tag in tags if tag]
141
+
142
+ # Handle string format (legacy)
143
+ if isinstance(tags, str):
144
+ try:
145
+ # Parse string representation of list
146
+ parsed_tags = ast.literal_eval(tags)
147
+ if isinstance(parsed_tags, list):
148
+ return [str(tag) for tag in parsed_tags if tag]
149
+ except (ValueError, SyntaxError):
150
+ # If parsing fails, treat as single tag
151
+ return [tags] if tags.strip() else []
152
+
153
+ return [] # pragma: no cover
154
+
115
155
  async def index_entity(
116
156
  self,
117
157
  entity: Entity,
118
158
  background_tasks: Optional[BackgroundTasks] = None,
159
+ ) -> None:
160
+ if background_tasks:
161
+ background_tasks.add_task(self.index_entity_data, entity)
162
+ else:
163
+ await self.index_entity_data(entity)
164
+
165
+ async def index_entity_data(
166
+ self,
167
+ entity: Entity,
168
+ ) -> None:
169
+ # delete all search index data associated with entity
170
+ await self.repository.delete_by_entity_id(entity_id=entity.id)
171
+
172
+ # reindex
173
+ await self.index_entity_markdown(
174
+ entity
175
+ ) if entity.is_markdown else await self.index_entity_file(entity)
176
+
177
+ async def index_entity_file(
178
+ self,
179
+ entity: Entity,
180
+ ) -> None:
181
+ # Index entity file with no content
182
+ await self.repository.index_item(
183
+ SearchIndexRow(
184
+ id=entity.id,
185
+ entity_id=entity.id,
186
+ type=SearchItemType.ENTITY.value,
187
+ title=entity.title,
188
+ file_path=entity.file_path,
189
+ metadata={
190
+ "entity_type": entity.entity_type,
191
+ },
192
+ created_at=entity.created_at,
193
+ updated_at=entity.updated_at,
194
+ project_id=entity.project_id,
195
+ )
196
+ )
197
+
198
+ async def index_entity_markdown(
199
+ self,
200
+ entity: Entity,
119
201
  ) -> None:
120
202
  """Index an entity and all its observations and relations.
121
203
 
@@ -123,67 +205,80 @@ class SearchService:
123
205
  1. Entities
124
206
  - permalink: direct from entity (e.g., "specs/search")
125
207
  - file_path: physical file location
208
+ - project_id: project context for isolation
126
209
 
127
210
  2. Observations
128
211
  - permalink: entity permalink + /observations/id (e.g., "specs/search/observations/123")
129
212
  - file_path: parent entity's file (where observation is defined)
213
+ - project_id: inherited from parent entity
130
214
 
131
215
  3. Relations (only index outgoing relations defined in this file)
132
216
  - permalink: from_entity/relation_type/to_entity (e.g., "specs/search/implements/features/search-ui")
133
217
  - file_path: source entity's file (where relation is defined)
218
+ - project_id: inherited from source entity
134
219
 
135
220
  Each type gets its own row in the search index with appropriate metadata.
221
+ The project_id is automatically added by the repository when indexing.
136
222
  """
137
- if background_tasks:
138
- background_tasks.add_task(self.index_entity_data, entity)
139
- else:
140
- await self.index_entity_data(entity)
141
223
 
142
- async def index_entity_data(
143
- self,
144
- entity: Entity,
145
- ) -> None:
146
- """Actually perform the indexing."""
224
+ # Collect all search index rows to batch insert at the end
225
+ rows_to_index = []
147
226
 
148
- content_parts = []
227
+ content_stems = []
228
+ content_snippet = ""
149
229
  title_variants = self._generate_variants(entity.title)
150
- content_parts.extend(title_variants)
230
+ content_stems.extend(title_variants)
151
231
 
152
232
  content = await self.file_service.read_entity_content(entity)
153
233
  if content:
154
- content_parts.append(content)
234
+ content_stems.append(content)
235
+ content_snippet = f"{content[:250]}"
155
236
 
156
- content_parts.extend(self._generate_variants(entity.permalink))
157
- content_parts.extend(self._generate_variants(entity.file_path))
237
+ if entity.permalink:
238
+ content_stems.extend(self._generate_variants(entity.permalink))
158
239
 
159
- entity_content = "\n".join(p for p in content_parts if p and p.strip())
240
+ content_stems.extend(self._generate_variants(entity.file_path))
160
241
 
161
- # Index entity
162
- await self.repository.index_item(
242
+ # Add entity tags from frontmatter to search content
243
+ entity_tags = self._extract_entity_tags(entity)
244
+ if entity_tags:
245
+ content_stems.extend(entity_tags)
246
+
247
+ entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
248
+
249
+ # Add entity row
250
+ rows_to_index.append(
163
251
  SearchIndexRow(
164
252
  id=entity.id,
165
253
  type=SearchItemType.ENTITY.value,
166
254
  title=entity.title,
167
- content=entity_content,
255
+ content_stems=entity_content_stems,
256
+ content_snippet=content_snippet,
168
257
  permalink=entity.permalink,
169
258
  file_path=entity.file_path,
259
+ entity_id=entity.id,
170
260
  metadata={
171
261
  "entity_type": entity.entity_type,
172
262
  },
173
263
  created_at=entity.created_at,
174
264
  updated_at=entity.updated_at,
265
+ project_id=entity.project_id,
175
266
  )
176
267
  )
177
268
 
178
- # Index each observation with permalink
269
+ # Add observation rows
179
270
  for obs in entity.observations:
180
271
  # Index with parent entity's file path since that's where it's defined
181
- await self.repository.index_item(
272
+ obs_content_stems = "\n".join(
273
+ p for p in self._generate_variants(obs.content) if p and p.strip()
274
+ )
275
+ rows_to_index.append(
182
276
  SearchIndexRow(
183
277
  id=obs.id,
184
278
  type=SearchItemType.OBSERVATION.value,
185
- title=f"{obs.category}: {obs.content[:50]}...",
186
- content=obs.content,
279
+ title=f"{obs.category}: {obs.content[:100]}...",
280
+ content_stems=obs_content_stems,
281
+ content_snippet=obs.content,
187
282
  permalink=obs.permalink,
188
283
  file_path=entity.file_path,
189
284
  category=obs.category,
@@ -193,10 +288,11 @@ class SearchService:
193
288
  },
194
289
  created_at=entity.created_at,
195
290
  updated_at=entity.updated_at,
291
+ project_id=entity.project_id,
196
292
  )
197
293
  )
198
294
 
199
- # Only index outgoing relations (ones defined in this file)
295
+ # Add relation rows (only outgoing relations defined in this file)
200
296
  for rel in entity.outgoing_relations:
201
297
  # Create descriptive title showing the relationship
202
298
  relation_title = (
@@ -205,21 +301,63 @@ class SearchService:
205
301
  else f"{rel.from_entity.title}"
206
302
  )
207
303
 
208
- await self.repository.index_item(
304
+ rel_content_stems = "\n".join(
305
+ p for p in self._generate_variants(relation_title) if p and p.strip()
306
+ )
307
+ rows_to_index.append(
209
308
  SearchIndexRow(
210
309
  id=rel.id,
211
310
  title=relation_title,
212
311
  permalink=rel.permalink,
312
+ content_stems=rel_content_stems,
213
313
  file_path=entity.file_path,
214
314
  type=SearchItemType.RELATION.value,
315
+ entity_id=entity.id,
215
316
  from_id=rel.from_id,
216
317
  to_id=rel.to_id,
217
318
  relation_type=rel.relation_type,
218
319
  created_at=entity.created_at,
219
320
  updated_at=entity.updated_at,
321
+ project_id=entity.project_id,
220
322
  )
221
323
  )
222
324
 
223
- async def delete_by_permalink(self, path_id: str):
325
+ # Batch insert all rows at once
326
+ await self.repository.bulk_index_items(rows_to_index)
327
+
328
+ async def delete_by_permalink(self, permalink: str):
329
+ """Delete an item from the search index."""
330
+ await self.repository.delete_by_permalink(permalink)
331
+
332
+ async def delete_by_entity_id(self, entity_id: int):
224
333
  """Delete an item from the search index."""
225
- await self.repository.delete_by_permalink(path_id)
334
+ await self.repository.delete_by_entity_id(entity_id)
335
+
336
+ async def handle_delete(self, entity: Entity):
337
+ """Handle complete entity deletion from search index including observations and relations.
338
+
339
+ This replicates the logic from sync_service.handle_delete() to properly clean up
340
+ all search index entries for an entity and its related data.
341
+ """
342
+ logger.debug(
343
+ f"Cleaning up search index for entity_id={entity.id}, file_path={entity.file_path}, "
344
+ f"observations={len(entity.observations)}, relations={len(entity.outgoing_relations)}"
345
+ )
346
+
347
+ # Clean up search index - same logic as sync_service.handle_delete()
348
+ permalinks = (
349
+ [entity.permalink]
350
+ + [o.permalink for o in entity.observations]
351
+ + [r.permalink for r in entity.outgoing_relations]
352
+ )
353
+
354
+ logger.debug(
355
+ f"Deleting search index entries for entity_id={entity.id}, "
356
+ f"index_entries={len(permalinks)}"
357
+ )
358
+
359
+ for permalink in permalinks:
360
+ if permalink:
361
+ await self.delete_by_permalink(permalink)
362
+ else:
363
+ await self.delete_by_entity_id(entity.id)
@@ -1,5 +1,6 @@
1
- from .file_change_scanner import FileChangeScanner
1
+ """Basic Memory sync services."""
2
+
2
3
  from .sync_service import SyncService
3
4
  from .watch_service import WatchService
4
5
 
5
- __all__ = ["SyncService", "FileChangeScanner", "WatchService"]
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))