basic-memory 0.7.0__py3-none-any.whl → 0.17.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,16 +1,20 @@
1
1
  """Service for building rich context from the knowledge graph."""
2
2
 
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
4
  from datetime import datetime, timezone
5
5
  from typing import List, Optional, Tuple
6
6
 
7
+
7
8
  from loguru import logger
8
9
  from sqlalchemy import text
9
10
 
10
11
  from basic_memory.repository.entity_repository import EntityRepository
11
- from basic_memory.repository.search_repository import SearchRepository
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
12
15
  from basic_memory.schemas.memory import MemoryUrl, memory_url_path
13
16
  from basic_memory.schemas.search import SearchItemType
17
+ from basic_memory.utils import generate_permalink
14
18
 
15
19
 
16
20
  @dataclass
@@ -31,6 +35,38 @@ class ContextResultRow:
31
35
  entity_id: Optional[int] = None
32
36
 
33
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
+
34
70
  class ContextService:
35
71
  """Service for building rich context from memory:// URIs.
36
72
 
@@ -44,9 +80,11 @@ class ContextService:
44
80
  self,
45
81
  search_repository: SearchRepository,
46
82
  entity_repository: EntityRepository,
83
+ observation_repository: ObservationRepository,
47
84
  ):
48
85
  self.search_repository = search_repository
49
86
  self.entity_repository = entity_repository
87
+ self.observation_repository = observation_repository
50
88
 
51
89
  async def build_context(
52
90
  self,
@@ -57,31 +95,42 @@ class ContextService:
57
95
  limit=10,
58
96
  offset=0,
59
97
  max_related: int = 10,
60
- ):
98
+ include_observations: bool = True,
99
+ ) -> ContextResult:
61
100
  """Build rich context from a memory:// URI."""
62
101
  logger.debug(
63
102
  f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
64
103
  )
65
104
 
105
+ normalized_path: Optional[str] = None
66
106
  if memory_url:
67
107
  path = memory_url_path(memory_url)
68
- # Pattern matching - use search
69
- if "*" in path:
70
- logger.debug(f"Pattern search for '{path}'")
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}'")
71
120
  primary = await self.search_repository.search(
72
- permalink_match=path, limit=limit, offset=offset
121
+ permalink_match=normalized_path, limit=limit, offset=offset
73
122
  )
74
-
75
- # Direct lookup for exact path
76
123
  else:
77
- logger.debug(f"Direct lookup for '{path}'")
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}'")
78
127
  primary = await self.search_repository.search(
79
- permalink=path, limit=limit, offset=offset
128
+ permalink=normalized_path, limit=limit, offset=offset
80
129
  )
81
130
  else:
82
131
  logger.debug(f"Build context for '{types}'")
83
132
  primary = await self.search_repository.search(
84
- types=types, after_date=since, limit=limit, offset=offset
133
+ search_item_types=types, after_date=since, limit=limit, offset=offset
85
134
  )
86
135
 
87
136
  # Get type_id pairs for traversal
@@ -94,24 +143,78 @@ class ContextService:
94
143
  type_id_pairs, max_depth=depth, since=since, max_results=max_related
95
144
  )
96
145
  logger.debug(f"Found {len(related)} related results")
97
- for r in related:
98
- logger.debug(f"Found related {r.type}: {r.permalink}")
99
-
100
- # Build response
101
- return {
102
- "primary_results": primary,
103
- "related_results": related,
104
- "metadata": {
105
- "uri": memory_url_path(memory_url) if memory_url else None,
106
- "types": types if types else None,
107
- "depth": depth,
108
- "timeframe": since.isoformat() if since else None,
109
- "generated_at": datetime.now(timezone.utc).isoformat(),
110
- "matched_results": len(primary),
111
- "total_results": len(primary) + len(related),
112
- "total_relations": sum(1 for r in related if r.type == SearchItemType.RELATION),
113
- },
114
- }
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)
115
218
 
116
219
  async def find_related(
117
220
  self,
@@ -124,7 +227,6 @@ class ContextService:
124
227
 
125
228
  Uses recursive CTE to find:
126
229
  - Connected entities
127
- - Their observations
128
230
  - Relations that connect them
129
231
 
130
232
  Note on depth:
@@ -138,107 +240,344 @@ class ContextService:
138
240
  if not type_id_pairs:
139
241
  return []
140
242
 
141
- logger.debug(f"Finding connected items for {type_id_pairs} with depth {max_depth}")
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])
142
256
 
143
- # Build the VALUES clause directly since SQLite doesn't handle parameterized IN well
144
- values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
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
+ }
145
263
 
146
- # Parameters for bindings
147
- params = {"max_depth": max_depth, "max_results": max_results}
264
+ # Build date and timeframe filters conditionally based on since parameter
148
265
  if since:
149
- params["since_date"] = since.isoformat() # pyright: ignore
150
-
151
- # Build date filter
152
- date_filter = "AND base.created_at >= :since_date" if since else ""
153
- r1_date_filter = "AND r.created_at >= :since_date" if since else ""
154
- related_date_filter = "AND e.created_at >= :since_date" if since else ""
155
-
156
- query = text(f"""
157
- WITH RECURSIVE context_graph AS (
158
- -- Base case: seed items
159
- SELECT
160
- id,
161
- type,
162
- title,
163
- permalink,
164
- file_path,
165
- from_id,
166
- to_id,
167
- relation_type,
168
- content,
169
- category,
170
- entity_id,
266
+ # SQLite accepts ISO strings, but Postgres/asyncpg requires datetime objects
267
+ if isinstance(self.search_repository, PostgresSearchRepository): # pragma: no cover
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 # pragma: no cover
271
+ params["since_date"] = since_utc.replace(tzinfo=None) # pyright: ignore # pragma: no cover
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: # pragma: no cover
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( # pragma: no cover
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,
171
367
  0 as depth,
172
- id as root_id,
173
- created_at,
174
- created_at as relation_date,
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,
175
504
  0 as is_incoming
176
- FROM search_index base
177
- WHERE (base.type, base.id) IN ({values})
505
+ FROM entity e
506
+ WHERE e.id IN ({entity_id_values})
178
507
  {date_filter}
508
+ {project_filter}
179
509
 
180
- UNION ALL -- Allow same paths at different depths
510
+ UNION ALL
181
511
 
182
- -- Get relations from current entities
183
- SELECT DISTINCT
512
+ -- Get relations from current entities
513
+ SELECT
184
514
  r.id,
185
- r.type,
186
- r.title,
187
- r.permalink,
188
- r.file_path,
515
+ 'relation' as type,
516
+ r.relation_type || ': ' || r.to_name as title,
517
+ '' as permalink,
518
+ e_from.file_path,
189
519
  r.from_id,
190
520
  r.to_id,
191
521
  r.relation_type,
192
- r.content,
193
- r.category,
194
- r.entity_id,
195
- cg.depth + 1,
196
- cg.root_id,
197
- r.created_at,
198
- r.created_at as relation_date,
199
- CASE WHEN r.from_id = cg.id THEN 0 ELSE 1 END as is_incoming
200
- FROM context_graph cg
201
- JOIN search_index r ON (
202
- cg.type = 'entity' AND
203
- r.type = 'relation' AND
204
- (r.from_id = cg.id OR r.to_id = cg.id)
205
- {r1_date_filter}
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}
206
539
  )
207
- WHERE cg.depth < :max_depth
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)
208
543
 
209
544
  UNION ALL
210
545
 
211
546
  -- Get entities connected by relations
212
- SELECT DISTINCT
547
+ SELECT
213
548
  e.id,
214
- e.type,
549
+ 'entity' as type,
215
550
  e.title,
216
- e.permalink,
551
+ CASE
552
+ WHEN e.permalink IS NULL THEN ''
553
+ ELSE e.permalink
554
+ END as permalink,
217
555
  e.file_path,
218
- e.from_id,
219
- e.to_id,
220
- e.relation_type,
221
- e.content,
222
- e.category,
223
- e.entity_id,
224
- cg.depth + 1, -- Increment depth for entities
225
- cg.root_id,
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,
226
564
  e.created_at,
227
- cg.relation_date,
228
- cg.is_incoming
229
- FROM context_graph cg
230
- JOIN search_index e ON (
231
- cg.type = 'relation' AND
232
- e.type = 'entity' AND
233
- e.id = CASE
234
- WHEN cg.is_incoming = 0 THEN cg.to_id -- Fixed entity lookup
235
- ELSE cg.from_id
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
236
573
  END
237
- {related_date_filter}
574
+ {date_filter}
575
+ {project_filter}
238
576
  )
239
- WHERE cg.depth < :max_depth
577
+ WHERE eg.depth < :max_depth
578
+ {timeframe_condition}
240
579
  )
241
- SELECT DISTINCT
580
+ SELECT DISTINCT
242
581
  type,
243
582
  id,
244
583
  title,
@@ -253,36 +592,10 @@ class ContextService:
253
592
  MIN(depth) as depth,
254
593
  root_id,
255
594
  created_at
256
- FROM context_graph
257
- WHERE (type, id) NOT IN ({values})
258
- GROUP BY
259
- type, id, title, permalink, from_id, to_id,
260
- relation_type, category, entity_id,
261
- root_id, 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
262
599
  ORDER BY depth, type, id
263
600
  LIMIT :max_results
264
601
  """)
265
-
266
- result = await self.search_repository.execute_query(query, params=params)
267
- rows = result.all()
268
-
269
- context_rows = [
270
- ContextResultRow(
271
- type=row.type,
272
- id=row.id,
273
- title=row.title,
274
- permalink=row.permalink,
275
- file_path=row.file_path,
276
- from_id=row.from_id,
277
- to_id=row.to_id,
278
- relation_type=row.relation_type,
279
- content=row.content,
280
- category=row.category,
281
- entity_id=row.entity_id,
282
- depth=row.depth,
283
- root_id=row.root_id,
284
- created_at=row.created_at,
285
- )
286
- for row in rows
287
- ]
288
- return context_rows