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
@@ -0,0 +1,437 @@
1
+ """SQLite FTS5-based search repository implementation."""
2
+
3
+ import json
4
+ import re
5
+ from datetime import datetime
6
+ from typing import List, Optional
7
+
8
+
9
+ from loguru import logger
10
+ from sqlalchemy import text
11
+
12
+ from basic_memory import db
13
+ from basic_memory.models.search import CREATE_SEARCH_INDEX
14
+ from basic_memory.repository.search_index_row import SearchIndexRow
15
+ from basic_memory.repository.search_repository_base import SearchRepositoryBase
16
+ from basic_memory.schemas.search import SearchItemType
17
+
18
+
19
+ class SQLiteSearchRepository(SearchRepositoryBase):
20
+ """SQLite FTS5 implementation of search repository.
21
+
22
+ Uses SQLite's FTS5 virtual tables for full-text search with:
23
+ - MATCH operator for queries
24
+ - bm25() function for relevance scoring
25
+ - Special character quoting for syntax safety
26
+ - Prefix wildcard matching with *
27
+ """
28
+
29
+ async def init_search_index(self):
30
+ """Create FTS5 virtual table for search if it doesn't exist.
31
+
32
+ Uses CREATE VIRTUAL TABLE IF NOT EXISTS to preserve existing indexed data
33
+ across server restarts.
34
+ """
35
+ logger.info("Initializing SQLite FTS5 search index")
36
+ try:
37
+ async with db.scoped_session(self.session_maker) as session:
38
+ # Create FTS5 virtual table if it doesn't exist
39
+ await session.execute(CREATE_SEARCH_INDEX)
40
+ await session.commit()
41
+ except Exception as e: # pragma: no cover
42
+ logger.error(f"Error initializing search index: {e}")
43
+ raise e
44
+
45
+ def _prepare_boolean_query(self, query: str) -> str:
46
+ """Prepare a Boolean query by quoting individual terms while preserving operators.
47
+
48
+ Args:
49
+ query: A Boolean query like "tier1-test AND unicode" or "(hello OR world) NOT test"
50
+
51
+ Returns:
52
+ A properly formatted Boolean query with quoted terms that need quoting
53
+ """
54
+ # Define Boolean operators and their boundaries
55
+ boolean_pattern = r"(\bAND\b|\bOR\b|\bNOT\b)"
56
+
57
+ # Split the query by Boolean operators, keeping the operators
58
+ parts = re.split(boolean_pattern, query)
59
+
60
+ processed_parts = []
61
+ for part in parts:
62
+ part = part.strip()
63
+ if not part:
64
+ continue
65
+
66
+ # If it's a Boolean operator, keep it as is
67
+ if part in ["AND", "OR", "NOT"]:
68
+ processed_parts.append(part)
69
+ else:
70
+ # Handle parentheses specially - they should be preserved for grouping
71
+ if "(" in part or ")" in part:
72
+ # Parse parenthetical expressions carefully
73
+ processed_part = self._prepare_parenthetical_term(part)
74
+ processed_parts.append(processed_part)
75
+ else:
76
+ # This is a search term - for Boolean queries, don't add prefix wildcards
77
+ prepared_term = self._prepare_single_term(part, is_prefix=False)
78
+ processed_parts.append(prepared_term)
79
+
80
+ return " ".join(processed_parts)
81
+
82
+ def _prepare_parenthetical_term(self, term: str) -> str:
83
+ """Prepare a term that contains parentheses, preserving the parentheses for grouping.
84
+
85
+ Args:
86
+ term: A term that may contain parentheses like "(hello" or "world)" or "(hello OR world)"
87
+
88
+ Returns:
89
+ A properly formatted term with parentheses preserved
90
+ """
91
+ # Handle terms that start/end with parentheses but may contain quotable content
92
+ result = ""
93
+ i = 0
94
+ while i < len(term):
95
+ if term[i] in "()":
96
+ # Preserve parentheses as-is
97
+ result += term[i]
98
+ i += 1
99
+ else:
100
+ # Find the next parenthesis or end of string
101
+ start = i
102
+ while i < len(term) and term[i] not in "()":
103
+ i += 1
104
+
105
+ # Extract the content between parentheses
106
+ content = term[start:i].strip()
107
+ if content:
108
+ # Only quote if it actually needs quoting (has hyphens, special chars, etc)
109
+ # but don't quote if it's just simple words
110
+ if self._needs_quoting(content):
111
+ escaped_content = content.replace('"', '""')
112
+ result += f'"{escaped_content}"'
113
+ else:
114
+ result += content
115
+
116
+ return result
117
+
118
+ def _needs_quoting(self, term: str) -> bool:
119
+ """Check if a term needs to be quoted for FTS5 safety.
120
+
121
+ Args:
122
+ term: The term to check
123
+
124
+ Returns:
125
+ True if the term should be quoted
126
+ """
127
+ if not term or not term.strip():
128
+ return False
129
+
130
+ # Characters that indicate we should quote (excluding parentheses which are valid syntax)
131
+ needs_quoting_chars = [
132
+ " ",
133
+ ".",
134
+ ":",
135
+ ";",
136
+ ",",
137
+ "<",
138
+ ">",
139
+ "?",
140
+ "/",
141
+ "-",
142
+ "'",
143
+ '"',
144
+ "[",
145
+ "]",
146
+ "{",
147
+ "}",
148
+ "+",
149
+ "!",
150
+ "@",
151
+ "#",
152
+ "$",
153
+ "%",
154
+ "^",
155
+ "&",
156
+ "=",
157
+ "|",
158
+ "\\",
159
+ "~",
160
+ "`",
161
+ ]
162
+
163
+ return any(c in term for c in needs_quoting_chars)
164
+
165
+ def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
166
+ """Prepare a single search term (no Boolean operators).
167
+
168
+ Args:
169
+ term: A single search term
170
+ is_prefix: Whether to add prefix search capability (* suffix)
171
+
172
+ Returns:
173
+ A properly formatted single term
174
+ """
175
+ if not term or not term.strip():
176
+ return term
177
+
178
+ term = term.strip()
179
+
180
+ # Check if term is already a proper wildcard pattern (alphanumeric + *)
181
+ # e.g., "hello*", "test*world" - these should be left alone
182
+ if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
183
+ return term
184
+
185
+ # Characters that can cause FTS5 syntax errors when used as operators
186
+ # We're more conservative here - only quote when we detect problematic patterns
187
+ problematic_chars = [
188
+ '"',
189
+ "'",
190
+ "(",
191
+ ")",
192
+ "[",
193
+ "]",
194
+ "{",
195
+ "}",
196
+ "+",
197
+ "!",
198
+ "@",
199
+ "#",
200
+ "$",
201
+ "%",
202
+ "^",
203
+ "&",
204
+ "=",
205
+ "|",
206
+ "\\",
207
+ "~",
208
+ "`",
209
+ ]
210
+
211
+ # Characters that indicate we should quote (spaces, dots, colons, etc.)
212
+ # Adding hyphens here because FTS5 can have issues with hyphens followed by wildcards
213
+ needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-"]
214
+
215
+ # Check if term needs quoting
216
+ has_problematic = any(c in term for c in problematic_chars)
217
+ has_spaces_or_special = any(c in term for c in needs_quoting_chars)
218
+
219
+ if has_problematic or has_spaces_or_special:
220
+ # Handle multi-word queries differently from special character queries
221
+ if " " in term and not any(c in term for c in problematic_chars):
222
+ # Check if any individual word contains special characters that need quoting
223
+ words = term.strip().split()
224
+ has_special_in_words = any(
225
+ any(c in word for c in needs_quoting_chars if c != " ") for word in words
226
+ )
227
+
228
+ if not has_special_in_words:
229
+ # For multi-word queries with simple words (like "emoji unicode"),
230
+ # use boolean AND to handle word order variations
231
+ if is_prefix:
232
+ # Add prefix wildcard to each word for better matching
233
+ prepared_words = [f"{word}*" for word in words if word]
234
+ else:
235
+ prepared_words = words
236
+ term = " AND ".join(prepared_words)
237
+ else:
238
+ # If any word has special characters, quote the entire phrase
239
+ escaped_term = term.replace('"', '""')
240
+ if is_prefix and not ("/" in term and term.endswith(".md")):
241
+ term = f'"{escaped_term}"*'
242
+ else:
243
+ term = f'"{escaped_term}"' # pragma: no cover
244
+ else:
245
+ # For terms with problematic characters or file paths, use exact phrase matching
246
+ # Escape any existing quotes by doubling them
247
+ escaped_term = term.replace('"', '""')
248
+ # Quote the entire term to handle special characters safely
249
+ if is_prefix and not ("/" in term and term.endswith(".md")):
250
+ # For search terms (not file paths), add prefix matching
251
+ term = f'"{escaped_term}"*'
252
+ else:
253
+ # For file paths, use exact matching
254
+ term = f'"{escaped_term}"'
255
+ elif is_prefix:
256
+ # Only add wildcard for simple terms without special characters
257
+ term = f"{term}*"
258
+
259
+ return term
260
+
261
+ def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
262
+ """Prepare a search term for FTS5 query.
263
+
264
+ Args:
265
+ term: The search term to prepare
266
+ is_prefix: Whether to add prefix search capability (* suffix)
267
+
268
+ For FTS5:
269
+ - Boolean operators (AND, OR, NOT) are preserved for complex queries
270
+ - Terms with FTS5 special characters are quoted to prevent syntax errors
271
+ - Simple terms get prefix wildcards for better matching
272
+ """
273
+ # Check for explicit boolean operators - if present, process as Boolean query
274
+ boolean_operators = [" AND ", " OR ", " NOT "]
275
+ if any(op in f" {term} " for op in boolean_operators):
276
+ return self._prepare_boolean_query(term)
277
+
278
+ # For non-Boolean queries, use the single term preparation logic
279
+ return self._prepare_single_term(term, is_prefix)
280
+
281
+ async def search(
282
+ self,
283
+ search_text: Optional[str] = None,
284
+ permalink: Optional[str] = None,
285
+ permalink_match: Optional[str] = None,
286
+ title: Optional[str] = None,
287
+ types: Optional[List[str]] = None,
288
+ after_date: Optional[datetime] = None,
289
+ search_item_types: Optional[List[SearchItemType]] = None,
290
+ limit: int = 10,
291
+ offset: int = 0,
292
+ ) -> List[SearchIndexRow]:
293
+ """Search across all indexed content using SQLite FTS5."""
294
+ conditions = []
295
+ params = {}
296
+ order_by_clause = ""
297
+
298
+ # Handle text search for title and content
299
+ if search_text:
300
+ # Skip FTS for wildcard-only queries that would cause "unknown special query" errors
301
+ if search_text.strip() == "*" or search_text.strip() == "":
302
+ # For wildcard searches, don't add any text conditions - return all results
303
+ pass
304
+ else:
305
+ # Use _prepare_search_term to handle both Boolean and non-Boolean queries
306
+ processed_text = self._prepare_search_term(search_text.strip())
307
+ params["text"] = processed_text
308
+ conditions.append("(title MATCH :text OR content_stems MATCH :text)")
309
+
310
+ # Handle title match search
311
+ if title:
312
+ title_text = self._prepare_search_term(title.strip(), is_prefix=False)
313
+ params["title_text"] = title_text
314
+ conditions.append("title MATCH :title_text")
315
+
316
+ # Handle permalink exact search
317
+ if permalink:
318
+ params["permalink"] = permalink
319
+ conditions.append("permalink = :permalink")
320
+
321
+ # Handle permalink match search, supports *
322
+ if permalink_match:
323
+ # For GLOB patterns, don't use _prepare_search_term as it will quote slashes
324
+ # GLOB patterns need to preserve their syntax
325
+ permalink_text = permalink_match.lower().strip()
326
+ params["permalink"] = permalink_text
327
+ if "*" in permalink_match:
328
+ conditions.append("permalink GLOB :permalink")
329
+ else:
330
+ # For exact matches without *, we can use FTS5 MATCH
331
+ # but only prepare the term if it doesn't look like a path
332
+ if "/" in permalink_text:
333
+ conditions.append("permalink = :permalink")
334
+ else:
335
+ permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
336
+ params["permalink"] = permalink_text
337
+ conditions.append("permalink MATCH :permalink")
338
+
339
+ # Handle entity type filter
340
+ if search_item_types:
341
+ type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
342
+ conditions.append(f"type IN ({type_list})")
343
+
344
+ # Handle type filter
345
+ if types:
346
+ type_list = ", ".join(f"'{t}'" for t in types)
347
+ conditions.append(f"json_extract(metadata, '$.entity_type') IN ({type_list})")
348
+
349
+ # Handle date filter using datetime() for proper comparison
350
+ if after_date:
351
+ params["after_date"] = after_date
352
+ conditions.append("datetime(created_at) > datetime(:after_date)")
353
+
354
+ # order by most recent first
355
+ order_by_clause = ", updated_at DESC"
356
+
357
+ # Always filter by project_id
358
+ params["project_id"] = self.project_id
359
+ conditions.append("project_id = :project_id")
360
+
361
+ # set limit on search query
362
+ params["limit"] = limit
363
+ params["offset"] = offset
364
+
365
+ # Build WHERE clause
366
+ where_clause = " AND ".join(conditions) if conditions else "1=1"
367
+
368
+ sql = f"""
369
+ SELECT
370
+ project_id,
371
+ id,
372
+ title,
373
+ permalink,
374
+ file_path,
375
+ type,
376
+ metadata,
377
+ from_id,
378
+ to_id,
379
+ relation_type,
380
+ entity_id,
381
+ content_snippet,
382
+ category,
383
+ created_at,
384
+ updated_at,
385
+ bm25(search_index) as score
386
+ FROM search_index
387
+ WHERE {where_clause}
388
+ ORDER BY score ASC {order_by_clause}
389
+ LIMIT :limit
390
+ OFFSET :offset
391
+ """
392
+
393
+ logger.trace(f"Search {sql} params: {params}")
394
+ try:
395
+ async with db.scoped_session(self.session_maker) as session:
396
+ result = await session.execute(text(sql), params)
397
+ rows = result.fetchall()
398
+ except Exception as e:
399
+ # Handle FTS5 syntax errors and provide user-friendly feedback
400
+ if "fts5: syntax error" in str(e).lower(): # pragma: no cover
401
+ logger.warning(f"FTS5 syntax error for search term: {search_text}, error: {e}")
402
+ # Return empty results rather than crashing
403
+ return []
404
+ else:
405
+ # Re-raise other database errors
406
+ logger.error(f"Database error during search: {e}")
407
+ raise
408
+
409
+ results = [
410
+ SearchIndexRow(
411
+ project_id=self.project_id,
412
+ id=row.id,
413
+ title=row.title,
414
+ permalink=row.permalink,
415
+ file_path=row.file_path,
416
+ type=row.type,
417
+ score=row.score,
418
+ metadata=json.loads(row.metadata) if row.metadata else {},
419
+ from_id=row.from_id,
420
+ to_id=row.to_id,
421
+ relation_type=row.relation_type,
422
+ entity_id=row.entity_id,
423
+ content_snippet=row.content_snippet,
424
+ category=row.category,
425
+ created_at=row.created_at,
426
+ updated_at=row.updated_at,
427
+ )
428
+ for row in rows
429
+ ]
430
+
431
+ logger.trace(f"Found {len(results)} search results")
432
+ for r in results:
433
+ logger.trace(
434
+ f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
435
+ )
436
+
437
+ return results
@@ -0,0 +1,61 @@
1
+ """Runtime mode resolution for Basic Memory.
2
+
3
+ This module centralizes runtime mode detection, ensuring cloud/local/test
4
+ determination happens in one place rather than scattered across modules.
5
+
6
+ Composition roots (containers) read ConfigManager and use this module
7
+ to resolve the runtime mode, then pass the result downstream.
8
+ """
9
+
10
+ from enum import Enum, auto
11
+
12
+
13
+ class RuntimeMode(Enum):
14
+ """Runtime modes for Basic Memory."""
15
+
16
+ LOCAL = auto() # Local standalone mode (default)
17
+ CLOUD = auto() # Cloud mode with remote sync
18
+ TEST = auto() # Test environment
19
+
20
+ @property
21
+ def is_cloud(self) -> bool:
22
+ return self == RuntimeMode.CLOUD
23
+
24
+ @property
25
+ def is_local(self) -> bool:
26
+ return self == RuntimeMode.LOCAL
27
+
28
+ @property
29
+ def is_test(self) -> bool:
30
+ return self == RuntimeMode.TEST
31
+
32
+
33
+ def resolve_runtime_mode(
34
+ cloud_mode_enabled: bool,
35
+ is_test_env: bool,
36
+ ) -> RuntimeMode:
37
+ """Resolve the runtime mode from configuration flags.
38
+
39
+ This is the single source of truth for mode resolution.
40
+ Composition roots call this with config values they've read.
41
+
42
+ Args:
43
+ cloud_mode_enabled: Whether cloud mode is enabled in config
44
+ is_test_env: Whether running in test environment
45
+
46
+ Returns:
47
+ The resolved RuntimeMode
48
+ """
49
+ # Trigger: test environment is detected
50
+ # Why: tests need special handling (no file sync, isolated DB)
51
+ # Outcome: returns TEST mode, skipping cloud mode check
52
+ if is_test_env:
53
+ return RuntimeMode.TEST
54
+
55
+ # Trigger: cloud mode is enabled in config
56
+ # Why: cloud mode changes auth, sync, and API behavior
57
+ # Outcome: returns CLOUD mode for remote-first behavior
58
+ if cloud_mode_enabled:
59
+ return RuntimeMode.CLOUD
60
+
61
+ return RuntimeMode.LOCAL
@@ -37,11 +37,19 @@ from basic_memory.schemas.response import (
37
37
  DeleteEntitiesResponse,
38
38
  )
39
39
 
40
- # Discovery and analytics models
41
- from basic_memory.schemas.discovery import (
42
- EntityTypeList,
43
- ObservationCategoryList,
44
- TypedEntityList,
40
+ from basic_memory.schemas.project_info import (
41
+ ProjectStatistics,
42
+ ActivityMetrics,
43
+ SystemStatus,
44
+ ProjectInfoResponse,
45
+ )
46
+
47
+ from basic_memory.schemas.directory import (
48
+ DirectoryNode,
49
+ )
50
+
51
+ from basic_memory.schemas.sync_report import (
52
+ SyncReportResponse,
45
53
  )
46
54
 
47
55
  # For convenient imports, export all models
@@ -66,8 +74,13 @@ __all__ = [
66
74
  "DeleteEntitiesResponse",
67
75
  # Delete Operations
68
76
  "DeleteEntitiesRequest",
69
- # Discovery and Analytics
70
- "EntityTypeList",
71
- "ObservationCategoryList",
72
- "TypedEntityList",
77
+ # Project Info
78
+ "ProjectStatistics",
79
+ "ActivityMetrics",
80
+ "SystemStatus",
81
+ "ProjectInfoResponse",
82
+ # Directory
83
+ "DirectoryNode",
84
+ # Sync
85
+ "SyncReportResponse",
73
86
  ]