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,601 @@
1
+ """Service for building rich context from the knowledge graph."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import List, Optional, Tuple
6
+
7
+
8
+ from loguru import logger
9
+ from sqlalchemy import text
10
+
11
+ from basic_memory.repository.entity_repository import EntityRepository
12
+ from basic_memory.repository.observation_repository import ObservationRepository
13
+ from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
14
+ from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
15
+ from basic_memory.schemas.memory import MemoryUrl, memory_url_path
16
+ from basic_memory.schemas.search import SearchItemType
17
+ from basic_memory.utils import generate_permalink
18
+
19
+
20
+ @dataclass
21
+ class ContextResultRow:
22
+ type: str
23
+ id: int
24
+ title: str
25
+ permalink: str
26
+ file_path: str
27
+ depth: int
28
+ root_id: int
29
+ created_at: datetime
30
+ from_id: Optional[int] = None
31
+ to_id: Optional[int] = None
32
+ relation_type: Optional[str] = None
33
+ content: Optional[str] = None
34
+ category: Optional[str] = None
35
+ entity_id: Optional[int] = None
36
+
37
+
38
+ @dataclass
39
+ class ContextResultItem:
40
+ """A hierarchical result containing a primary item with its observations and related items."""
41
+
42
+ primary_result: ContextResultRow | SearchIndexRow
43
+ observations: List[ContextResultRow] = field(default_factory=list)
44
+ related_results: List[ContextResultRow] = field(default_factory=list)
45
+
46
+
47
+ @dataclass
48
+ class ContextMetadata:
49
+ """Metadata about a context result."""
50
+
51
+ uri: Optional[str] = None
52
+ types: Optional[List[SearchItemType]] = None
53
+ depth: int = 1
54
+ timeframe: Optional[str] = None
55
+ generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
56
+ primary_count: int = 0
57
+ related_count: int = 0
58
+ total_observations: int = 0
59
+ total_relations: int = 0
60
+
61
+
62
+ @dataclass
63
+ class ContextResult:
64
+ """Complete context result with metadata."""
65
+
66
+ results: List[ContextResultItem] = field(default_factory=list)
67
+ metadata: ContextMetadata = field(default_factory=ContextMetadata)
68
+
69
+
70
+ class ContextService:
71
+ """Service for building rich context from memory:// URIs.
72
+
73
+ Handles three types of context building:
74
+ 1. Direct permalink lookup - exact match on path
75
+ 2. Pattern matching - using * wildcards
76
+ 3. Special modes via params (e.g., 'related')
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ search_repository: SearchRepository,
82
+ entity_repository: EntityRepository,
83
+ observation_repository: ObservationRepository,
84
+ ):
85
+ self.search_repository = search_repository
86
+ self.entity_repository = entity_repository
87
+ self.observation_repository = observation_repository
88
+
89
+ async def build_context(
90
+ self,
91
+ memory_url: Optional[MemoryUrl] = None,
92
+ types: Optional[List[SearchItemType]] = None,
93
+ depth: int = 1,
94
+ since: Optional[datetime] = None,
95
+ limit=10,
96
+ offset=0,
97
+ max_related: int = 10,
98
+ include_observations: bool = True,
99
+ ) -> ContextResult:
100
+ """Build rich context from a memory:// URI."""
101
+ logger.debug(
102
+ f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
103
+ )
104
+
105
+ normalized_path: Optional[str] = None
106
+ if memory_url:
107
+ path = memory_url_path(memory_url)
108
+ # Check for wildcards before normalization
109
+ has_wildcard = "*" in path
110
+
111
+ if has_wildcard:
112
+ # For wildcard patterns, normalize each segment separately to preserve the *
113
+ parts = path.split("*")
114
+ normalized_parts = [
115
+ generate_permalink(part, split_extension=False) if part else ""
116
+ for part in parts
117
+ ]
118
+ normalized_path = "*".join(normalized_parts)
119
+ logger.debug(f"Pattern search for '{normalized_path}'")
120
+ primary = await self.search_repository.search(
121
+ permalink_match=normalized_path, limit=limit, offset=offset
122
+ )
123
+ else:
124
+ # For exact paths, normalize the whole thing
125
+ normalized_path = generate_permalink(path, split_extension=False)
126
+ logger.debug(f"Direct lookup for '{normalized_path}'")
127
+ primary = await self.search_repository.search(
128
+ permalink=normalized_path, limit=limit, offset=offset
129
+ )
130
+ else:
131
+ logger.debug(f"Build context for '{types}'")
132
+ primary = await self.search_repository.search(
133
+ search_item_types=types, after_date=since, limit=limit, offset=offset
134
+ )
135
+
136
+ # Get type_id pairs for traversal
137
+
138
+ type_id_pairs = [(r.type, r.id) for r in primary] if primary else []
139
+ logger.debug(f"found primary type_id_pairs: {len(type_id_pairs)}")
140
+
141
+ # Find related content
142
+ related = await self.find_related(
143
+ type_id_pairs, max_depth=depth, since=since, max_results=max_related
144
+ )
145
+ logger.debug(f"Found {len(related)} related results")
146
+
147
+ # Collect entity IDs from primary and related results
148
+ entity_ids = []
149
+ for result in primary:
150
+ if result.type == SearchItemType.ENTITY.value:
151
+ entity_ids.append(result.id)
152
+
153
+ for result in related:
154
+ if result.type == SearchItemType.ENTITY.value:
155
+ entity_ids.append(result.id)
156
+
157
+ # Fetch observations for all entities if requested
158
+ observations_by_entity = {}
159
+ if include_observations and entity_ids:
160
+ # Use our observation repository to get observations for all entities at once
161
+ observations_by_entity = await self.observation_repository.find_by_entities(entity_ids)
162
+ logger.debug(f"Found observations for {len(observations_by_entity)} entities")
163
+
164
+ # Create metadata dataclass
165
+ metadata = ContextMetadata(
166
+ uri=normalized_path if memory_url else None,
167
+ types=types,
168
+ depth=depth,
169
+ timeframe=since.isoformat() if since else None,
170
+ primary_count=len(primary),
171
+ related_count=len(related),
172
+ total_observations=sum(len(obs) for obs in observations_by_entity.values()),
173
+ total_relations=sum(1 for r in related if r.type == SearchItemType.RELATION),
174
+ )
175
+
176
+ # Build context results list directly with ContextResultItem objects
177
+ context_results = []
178
+
179
+ # For each primary result
180
+ for primary_item in primary:
181
+ # Find all related items with this primary item as root
182
+ related_to_primary = [r for r in related if r.root_id == primary_item.id]
183
+
184
+ # Get observations for this item if it's an entity
185
+ item_observations = []
186
+ if primary_item.type == SearchItemType.ENTITY.value and include_observations:
187
+ # Convert Observation models to ContextResultRows
188
+ for obs in observations_by_entity.get(primary_item.id, []):
189
+ item_observations.append(
190
+ ContextResultRow(
191
+ type="observation",
192
+ id=obs.id,
193
+ title=f"{obs.category}: {obs.content[:50]}...",
194
+ permalink=generate_permalink(
195
+ f"{primary_item.permalink}/observations/{obs.category}/{obs.content}"
196
+ ),
197
+ file_path=primary_item.file_path,
198
+ content=obs.content,
199
+ category=obs.category,
200
+ entity_id=primary_item.id,
201
+ depth=0,
202
+ root_id=primary_item.id,
203
+ created_at=primary_item.created_at, # created_at time from entity
204
+ )
205
+ )
206
+
207
+ # Create ContextResultItem directly
208
+ context_item = ContextResultItem(
209
+ primary_result=primary_item,
210
+ observations=item_observations,
211
+ related_results=related_to_primary,
212
+ )
213
+
214
+ context_results.append(context_item)
215
+
216
+ # Return the structured ContextResult
217
+ return ContextResult(results=context_results, metadata=metadata)
218
+
219
+ async def find_related(
220
+ self,
221
+ type_id_pairs: List[Tuple[str, int]],
222
+ max_depth: int = 1,
223
+ since: Optional[datetime] = None,
224
+ max_results: int = 10,
225
+ ) -> List[ContextResultRow]:
226
+ """Find items connected through relations.
227
+
228
+ Uses recursive CTE to find:
229
+ - Connected entities
230
+ - Relations that connect them
231
+
232
+ Note on depth:
233
+ Each traversal step requires two depth levels - one to find the relation,
234
+ and another to follow that relation to an entity. So a max_depth of 4 allows
235
+ traversal through two entities (relation->entity->relation->entity), while reaching
236
+ an entity three steps away requires max_depth=6 (relation->entity->relation->entity->relation->entity).
237
+ """
238
+ max_depth = max_depth * 2
239
+
240
+ if not type_id_pairs:
241
+ return []
242
+
243
+ # Extract entity IDs from type_id_pairs for the optimized query
244
+ entity_ids = [i for t, i in type_id_pairs if t == "entity"]
245
+
246
+ if not entity_ids:
247
+ logger.debug("No entity IDs found in type_id_pairs")
248
+ return []
249
+
250
+ logger.debug(
251
+ f"Finding connected items for {len(entity_ids)} entities with depth {max_depth}"
252
+ )
253
+
254
+ # Build the VALUES clause for entity IDs
255
+ entity_id_values = ", ".join([str(i) for i in entity_ids])
256
+
257
+ # Parameters for bindings - include project_id for security filtering
258
+ params = {
259
+ "max_depth": max_depth,
260
+ "max_results": max_results,
261
+ "project_id": self.search_repository.project_id,
262
+ }
263
+
264
+ # Build date and timeframe filters conditionally based on since parameter
265
+ if since:
266
+ # SQLite accepts ISO strings, but Postgres/asyncpg requires datetime objects
267
+ if isinstance(self.search_repository, PostgresSearchRepository):
268
+ # asyncpg expects timezone-NAIVE datetime in UTC for DateTime(timezone=True) columns
269
+ # even though the column stores timezone-aware values
270
+ since_utc = since.astimezone(timezone.utc) if since.tzinfo else since
271
+ params["since_date"] = since_utc.replace(tzinfo=None) # pyright: ignore
272
+ else:
273
+ params["since_date"] = since.isoformat() # pyright: ignore
274
+ date_filter = "AND e.created_at >= :since_date"
275
+ relation_date_filter = "AND e_from.created_at >= :since_date"
276
+ timeframe_condition = "AND eg.relation_date >= :since_date"
277
+ else:
278
+ date_filter = ""
279
+ relation_date_filter = ""
280
+ timeframe_condition = ""
281
+
282
+ # Add project filtering for security - ensure all entities and relations belong to the same project
283
+ project_filter = "AND e.project_id = :project_id"
284
+ relation_project_filter = "AND e_from.project_id = :project_id"
285
+
286
+ # Use a CTE that operates directly on entity and relation tables
287
+ # This avoids the overhead of the search_index virtual table
288
+ # Note: Postgres and SQLite have different CTE limitations:
289
+ # - Postgres: doesn't allow multiple UNION ALL branches referencing the CTE
290
+ # - SQLite: doesn't support LATERAL joins
291
+ # So we need different queries for each database backend
292
+
293
+ # Detect database backend
294
+ is_postgres = isinstance(self.search_repository, PostgresSearchRepository)
295
+
296
+ if is_postgres:
297
+ query = self._build_postgres_query(
298
+ entity_id_values,
299
+ date_filter,
300
+ project_filter,
301
+ relation_date_filter,
302
+ relation_project_filter,
303
+ timeframe_condition,
304
+ )
305
+ else:
306
+ # SQLite needs VALUES clause for exclusion (not needed for Postgres)
307
+ values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
308
+ query = self._build_sqlite_query(
309
+ entity_id_values,
310
+ date_filter,
311
+ project_filter,
312
+ relation_date_filter,
313
+ relation_project_filter,
314
+ timeframe_condition,
315
+ values,
316
+ )
317
+
318
+ result = await self.search_repository.execute_query(query, params=params)
319
+ rows = result.all()
320
+
321
+ context_rows = [
322
+ ContextResultRow(
323
+ type=row.type,
324
+ id=row.id,
325
+ title=row.title,
326
+ permalink=row.permalink,
327
+ file_path=row.file_path,
328
+ from_id=row.from_id,
329
+ to_id=row.to_id,
330
+ relation_type=row.relation_type,
331
+ content=row.content,
332
+ category=row.category,
333
+ entity_id=row.entity_id,
334
+ depth=row.depth,
335
+ root_id=row.root_id,
336
+ created_at=row.created_at,
337
+ )
338
+ for row in rows
339
+ ]
340
+ return context_rows
341
+
342
+ def _build_postgres_query(
343
+ self,
344
+ entity_id_values: str,
345
+ date_filter: str,
346
+ project_filter: str,
347
+ relation_date_filter: str,
348
+ relation_project_filter: str,
349
+ timeframe_condition: str,
350
+ ):
351
+ """Build Postgres-specific CTE query using LATERAL joins."""
352
+ return text(f"""
353
+ WITH RECURSIVE entity_graph AS (
354
+ -- Base case: seed entities
355
+ SELECT
356
+ e.id,
357
+ 'entity' as type,
358
+ e.title,
359
+ e.permalink,
360
+ e.file_path,
361
+ CAST(NULL AS INTEGER) as from_id,
362
+ CAST(NULL AS INTEGER) as to_id,
363
+ CAST(NULL AS TEXT) as relation_type,
364
+ CAST(NULL AS TEXT) as content,
365
+ CAST(NULL AS TEXT) as category,
366
+ CAST(NULL AS INTEGER) as entity_id,
367
+ 0 as depth,
368
+ e.id as root_id,
369
+ e.created_at,
370
+ e.created_at as relation_date
371
+ FROM entity e
372
+ WHERE e.id IN ({entity_id_values})
373
+ {date_filter}
374
+ {project_filter}
375
+
376
+ UNION ALL
377
+
378
+ -- Fetch BOTH relations AND connected entities in a single recursive step
379
+ -- Postgres only allows ONE reference to the recursive CTE in the recursive term
380
+ -- We use CROSS JOIN LATERAL to generate two rows (relation + entity) from each traversal
381
+ SELECT
382
+ CASE
383
+ WHEN step_type = 1 THEN r.id
384
+ ELSE e.id
385
+ END as id,
386
+ CASE
387
+ WHEN step_type = 1 THEN 'relation'
388
+ ELSE 'entity'
389
+ END as type,
390
+ CASE
391
+ WHEN step_type = 1 THEN r.relation_type || ': ' || r.to_name
392
+ ELSE e.title
393
+ END as title,
394
+ CASE
395
+ WHEN step_type = 1 THEN ''
396
+ ELSE COALESCE(e.permalink, '')
397
+ END as permalink,
398
+ CASE
399
+ WHEN step_type = 1 THEN e_from.file_path
400
+ ELSE e.file_path
401
+ END as file_path,
402
+ CASE
403
+ WHEN step_type = 1 THEN r.from_id
404
+ ELSE NULL
405
+ END as from_id,
406
+ CASE
407
+ WHEN step_type = 1 THEN r.to_id
408
+ ELSE NULL
409
+ END as to_id,
410
+ CASE
411
+ WHEN step_type = 1 THEN r.relation_type
412
+ ELSE NULL
413
+ END as relation_type,
414
+ CAST(NULL AS TEXT) as content,
415
+ CAST(NULL AS TEXT) as category,
416
+ CAST(NULL AS INTEGER) as entity_id,
417
+ eg.depth + step_type as depth,
418
+ eg.root_id,
419
+ CASE
420
+ WHEN step_type = 1 THEN e_from.created_at
421
+ ELSE e.created_at
422
+ END as created_at,
423
+ CASE
424
+ WHEN step_type = 1 THEN e_from.created_at
425
+ ELSE eg.relation_date
426
+ END as relation_date
427
+ FROM entity_graph eg
428
+ CROSS JOIN LATERAL (VALUES (1), (2)) AS steps(step_type)
429
+ JOIN relation r ON (
430
+ eg.type = 'entity' AND
431
+ (r.from_id = eg.id OR r.to_id = eg.id)
432
+ )
433
+ JOIN entity e_from ON (
434
+ r.from_id = e_from.id
435
+ {relation_project_filter}
436
+ )
437
+ LEFT JOIN entity e ON (
438
+ step_type = 2 AND
439
+ e.id = CASE
440
+ WHEN r.from_id = eg.id THEN r.to_id
441
+ ELSE r.from_id
442
+ END
443
+ {date_filter}
444
+ {project_filter}
445
+ )
446
+ WHERE eg.depth < :max_depth
447
+ AND (step_type = 1 OR (step_type = 2 AND e.id IS NOT NULL AND e.id != eg.id))
448
+ {timeframe_condition}
449
+ )
450
+ -- Materialize and filter
451
+ SELECT DISTINCT
452
+ type,
453
+ id,
454
+ title,
455
+ permalink,
456
+ file_path,
457
+ from_id,
458
+ to_id,
459
+ relation_type,
460
+ content,
461
+ category,
462
+ entity_id,
463
+ MIN(depth) as depth,
464
+ root_id,
465
+ created_at
466
+ FROM entity_graph
467
+ WHERE depth > 0
468
+ GROUP BY type, id, title, permalink, file_path, from_id, to_id,
469
+ relation_type, content, category, entity_id, root_id, created_at
470
+ ORDER BY depth, type, id
471
+ LIMIT :max_results
472
+ """)
473
+
474
+ def _build_sqlite_query(
475
+ self,
476
+ entity_id_values: str,
477
+ date_filter: str,
478
+ project_filter: str,
479
+ relation_date_filter: str,
480
+ relation_project_filter: str,
481
+ timeframe_condition: str,
482
+ values: str,
483
+ ):
484
+ """Build SQLite-specific CTE query using multiple UNION ALL branches."""
485
+ return text(f"""
486
+ WITH RECURSIVE entity_graph AS (
487
+ -- Base case: seed entities
488
+ SELECT
489
+ e.id,
490
+ 'entity' as type,
491
+ e.title,
492
+ e.permalink,
493
+ e.file_path,
494
+ NULL as from_id,
495
+ NULL as to_id,
496
+ NULL as relation_type,
497
+ NULL as content,
498
+ NULL as category,
499
+ NULL as entity_id,
500
+ 0 as depth,
501
+ e.id as root_id,
502
+ e.created_at,
503
+ e.created_at as relation_date,
504
+ 0 as is_incoming
505
+ FROM entity e
506
+ WHERE e.id IN ({entity_id_values})
507
+ {date_filter}
508
+ {project_filter}
509
+
510
+ UNION ALL
511
+
512
+ -- Get relations from current entities
513
+ SELECT
514
+ r.id,
515
+ 'relation' as type,
516
+ r.relation_type || ': ' || r.to_name as title,
517
+ '' as permalink,
518
+ e_from.file_path,
519
+ r.from_id,
520
+ r.to_id,
521
+ r.relation_type,
522
+ NULL as content,
523
+ NULL as category,
524
+ NULL as entity_id,
525
+ eg.depth + 1,
526
+ eg.root_id,
527
+ e_from.created_at,
528
+ e_from.created_at as relation_date,
529
+ CASE WHEN r.from_id = eg.id THEN 0 ELSE 1 END as is_incoming
530
+ FROM entity_graph eg
531
+ JOIN relation r ON (
532
+ eg.type = 'entity' AND
533
+ (r.from_id = eg.id OR r.to_id = eg.id)
534
+ )
535
+ JOIN entity e_from ON (
536
+ r.from_id = e_from.id
537
+ {relation_date_filter}
538
+ {relation_project_filter}
539
+ )
540
+ LEFT JOIN entity e_to ON (r.to_id = e_to.id)
541
+ WHERE eg.depth < :max_depth
542
+ AND (r.to_id IS NULL OR e_to.project_id = :project_id)
543
+
544
+ UNION ALL
545
+
546
+ -- Get entities connected by relations
547
+ SELECT
548
+ e.id,
549
+ 'entity' as type,
550
+ e.title,
551
+ CASE
552
+ WHEN e.permalink IS NULL THEN ''
553
+ ELSE e.permalink
554
+ END as permalink,
555
+ e.file_path,
556
+ NULL as from_id,
557
+ NULL as to_id,
558
+ NULL as relation_type,
559
+ NULL as content,
560
+ NULL as category,
561
+ NULL as entity_id,
562
+ eg.depth + 1,
563
+ eg.root_id,
564
+ e.created_at,
565
+ eg.relation_date,
566
+ eg.is_incoming
567
+ FROM entity_graph eg
568
+ JOIN entity e ON (
569
+ eg.type = 'relation' AND
570
+ e.id = CASE
571
+ WHEN eg.is_incoming = 0 THEN eg.to_id
572
+ ELSE eg.from_id
573
+ END
574
+ {date_filter}
575
+ {project_filter}
576
+ )
577
+ WHERE eg.depth < :max_depth
578
+ {timeframe_condition}
579
+ )
580
+ SELECT DISTINCT
581
+ type,
582
+ id,
583
+ title,
584
+ permalink,
585
+ file_path,
586
+ from_id,
587
+ to_id,
588
+ relation_type,
589
+ content,
590
+ category,
591
+ entity_id,
592
+ MIN(depth) as depth,
593
+ root_id,
594
+ created_at
595
+ FROM entity_graph
596
+ WHERE depth > 0
597
+ GROUP BY type, id, title, permalink, file_path, from_id, to_id,
598
+ relation_type, content, category, entity_id, root_id, created_at
599
+ ORDER BY depth, type, id
600
+ LIMIT :max_results
601
+ """)