basic-memory 0.7.0__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 (150) 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 +64 -18
  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 +166 -21
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +9 -64
  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 +119 -4
  23. basic_memory/api/routers/search_router.py +5 -5
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +43 -9
  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 +28 -12
  41. basic_memory/cli/commands/import_chatgpt.py +40 -220
  42. basic_memory/cli/commands/import_claude_conversations.py +41 -168
  43. basic_memory/cli/commands/import_claude_projects.py +46 -157
  44. basic_memory/cli/commands/import_memory_json.py +48 -108
  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 +50 -33
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +8 -7
  50. basic_memory/config.py +477 -23
  51. basic_memory/db.py +168 -17
  52. basic_memory/deps.py +251 -25
  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 -23
  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 +411 -62
  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 +187 -25
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +56 -2
  115. basic_memory/schemas/response.py +1 -1
  116. basic_memory/schemas/search.py +31 -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 +241 -104
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +590 -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 +49 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +168 -32
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1180 -109
  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 +383 -51
  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.7.0.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 -206
  140. basic_memory/cli/commands/tools.py +0 -157
  141. basic_memory/mcp/tools/knowledge.py +0 -68
  142. basic_memory/mcp/tools/memory.py +0 -170
  143. basic_memory/mcp/tools/notes.py +0 -202
  144. basic_memory/schemas/discovery.py +0 -28
  145. basic_memory/sync/file_change_scanner.py +0 -158
  146. basic_memory/sync/utils.py +0 -31
  147. basic_memory-0.7.0.dist-info/METADATA +0 -378
  148. basic_memory-0.7.0.dist-info/RECORD +0 -82
  149. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  150. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,6 @@
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
 
@@ -8,9 +8,11 @@ from loguru import logger
8
8
  from sqlalchemy import text
9
9
 
10
10
  from basic_memory.repository.entity_repository import EntityRepository
11
- from basic_memory.repository.search_repository import SearchRepository
11
+ from basic_memory.repository.observation_repository import ObservationRepository
12
+ from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
12
13
  from basic_memory.schemas.memory import MemoryUrl, memory_url_path
13
14
  from basic_memory.schemas.search import SearchItemType
15
+ from basic_memory.utils import generate_permalink
14
16
 
15
17
 
16
18
  @dataclass
@@ -31,6 +33,38 @@ class ContextResultRow:
31
33
  entity_id: Optional[int] = None
32
34
 
33
35
 
36
+ @dataclass
37
+ class ContextResultItem:
38
+ """A hierarchical result containing a primary item with its observations and related items."""
39
+
40
+ primary_result: ContextResultRow | SearchIndexRow
41
+ observations: List[ContextResultRow] = field(default_factory=list)
42
+ related_results: List[ContextResultRow] = field(default_factory=list)
43
+
44
+
45
+ @dataclass
46
+ class ContextMetadata:
47
+ """Metadata about a context result."""
48
+
49
+ uri: Optional[str] = None
50
+ types: Optional[List[SearchItemType]] = None
51
+ depth: int = 1
52
+ timeframe: Optional[str] = None
53
+ generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
54
+ primary_count: int = 0
55
+ related_count: int = 0
56
+ total_observations: int = 0
57
+ total_relations: int = 0
58
+
59
+
60
+ @dataclass
61
+ class ContextResult:
62
+ """Complete context result with metadata."""
63
+
64
+ results: List[ContextResultItem] = field(default_factory=list)
65
+ metadata: ContextMetadata = field(default_factory=ContextMetadata)
66
+
67
+
34
68
  class ContextService:
35
69
  """Service for building rich context from memory:// URIs.
36
70
 
@@ -44,9 +78,11 @@ class ContextService:
44
78
  self,
45
79
  search_repository: SearchRepository,
46
80
  entity_repository: EntityRepository,
81
+ observation_repository: ObservationRepository,
47
82
  ):
48
83
  self.search_repository = search_repository
49
84
  self.entity_repository = entity_repository
85
+ self.observation_repository = observation_repository
50
86
 
51
87
  async def build_context(
52
88
  self,
@@ -57,31 +93,42 @@ class ContextService:
57
93
  limit=10,
58
94
  offset=0,
59
95
  max_related: int = 10,
60
- ):
96
+ include_observations: bool = True,
97
+ ) -> ContextResult:
61
98
  """Build rich context from a memory:// URI."""
62
99
  logger.debug(
63
100
  f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
64
101
  )
65
102
 
103
+ normalized_path: Optional[str] = None
66
104
  if memory_url:
67
105
  path = memory_url_path(memory_url)
68
- # Pattern matching - use search
69
- if "*" in path:
70
- logger.debug(f"Pattern search for '{path}'")
106
+ # Check for wildcards before normalization
107
+ has_wildcard = "*" in path
108
+
109
+ if has_wildcard:
110
+ # For wildcard patterns, normalize each segment separately to preserve the *
111
+ parts = path.split("*")
112
+ normalized_parts = [
113
+ generate_permalink(part, split_extension=False) if part else ""
114
+ for part in parts
115
+ ]
116
+ normalized_path = "*".join(normalized_parts)
117
+ logger.debug(f"Pattern search for '{normalized_path}'")
71
118
  primary = await self.search_repository.search(
72
- permalink_match=path, limit=limit, offset=offset
119
+ permalink_match=normalized_path, limit=limit, offset=offset
73
120
  )
74
-
75
- # Direct lookup for exact path
76
121
  else:
77
- logger.debug(f"Direct lookup for '{path}'")
122
+ # For exact paths, normalize the whole thing
123
+ normalized_path = generate_permalink(path, split_extension=False)
124
+ logger.debug(f"Direct lookup for '{normalized_path}'")
78
125
  primary = await self.search_repository.search(
79
- permalink=path, limit=limit, offset=offset
126
+ permalink=normalized_path, limit=limit, offset=offset
80
127
  )
81
128
  else:
82
129
  logger.debug(f"Build context for '{types}'")
83
130
  primary = await self.search_repository.search(
84
- types=types, after_date=since, limit=limit, offset=offset
131
+ search_item_types=types, after_date=since, limit=limit, offset=offset
85
132
  )
86
133
 
87
134
  # Get type_id pairs for traversal
@@ -94,24 +141,78 @@ class ContextService:
94
141
  type_id_pairs, max_depth=depth, since=since, max_results=max_related
95
142
  )
96
143
  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
- }
144
+
145
+ # Collect entity IDs from primary and related results
146
+ entity_ids = []
147
+ for result in primary:
148
+ if result.type == SearchItemType.ENTITY.value:
149
+ entity_ids.append(result.id)
150
+
151
+ for result in related:
152
+ if result.type == SearchItemType.ENTITY.value:
153
+ entity_ids.append(result.id)
154
+
155
+ # Fetch observations for all entities if requested
156
+ observations_by_entity = {}
157
+ if include_observations and entity_ids:
158
+ # Use our observation repository to get observations for all entities at once
159
+ observations_by_entity = await self.observation_repository.find_by_entities(entity_ids)
160
+ logger.debug(f"Found observations for {len(observations_by_entity)} entities")
161
+
162
+ # Create metadata dataclass
163
+ metadata = ContextMetadata(
164
+ uri=normalized_path if memory_url else None,
165
+ types=types,
166
+ depth=depth,
167
+ timeframe=since.isoformat() if since else None,
168
+ primary_count=len(primary),
169
+ related_count=len(related),
170
+ total_observations=sum(len(obs) for obs in observations_by_entity.values()),
171
+ total_relations=sum(1 for r in related if r.type == SearchItemType.RELATION),
172
+ )
173
+
174
+ # Build context results list directly with ContextResultItem objects
175
+ context_results = []
176
+
177
+ # For each primary result
178
+ for primary_item in primary:
179
+ # Find all related items with this primary item as root
180
+ related_to_primary = [r for r in related if r.root_id == primary_item.id]
181
+
182
+ # Get observations for this item if it's an entity
183
+ item_observations = []
184
+ if primary_item.type == SearchItemType.ENTITY.value and include_observations:
185
+ # Convert Observation models to ContextResultRows
186
+ for obs in observations_by_entity.get(primary_item.id, []):
187
+ item_observations.append(
188
+ ContextResultRow(
189
+ type="observation",
190
+ id=obs.id,
191
+ title=f"{obs.category}: {obs.content[:50]}...",
192
+ permalink=generate_permalink(
193
+ f"{primary_item.permalink}/observations/{obs.category}/{obs.content}"
194
+ ),
195
+ file_path=primary_item.file_path,
196
+ content=obs.content,
197
+ category=obs.category,
198
+ entity_id=primary_item.id,
199
+ depth=0,
200
+ root_id=primary_item.id,
201
+ created_at=primary_item.created_at, # created_at time from entity
202
+ )
203
+ )
204
+
205
+ # Create ContextResultItem directly
206
+ context_item = ContextResultItem(
207
+ primary_result=primary_item,
208
+ observations=item_observations,
209
+ related_results=related_to_primary,
210
+ )
211
+
212
+ context_results.append(context_item)
213
+
214
+ # Return the structured ContextResult
215
+ return ContextResult(results=context_results, metadata=metadata)
115
216
 
116
217
  async def find_related(
117
218
  self,
@@ -124,7 +225,6 @@ class ContextService:
124
225
 
125
226
  Uses recursive CTE to find:
126
227
  - Connected entities
127
- - Their observations
128
228
  - Relations that connect them
129
229
 
130
230
  Note on depth:
@@ -138,105 +238,144 @@ class ContextService:
138
238
  if not type_id_pairs:
139
239
  return []
140
240
 
141
- logger.debug(f"Finding connected items for {type_id_pairs} with depth {max_depth}")
241
+ # Extract entity IDs from type_id_pairs for the optimized query
242
+ entity_ids = [i for t, i in type_id_pairs if t == "entity"]
243
+
244
+ if not entity_ids:
245
+ logger.debug("No entity IDs found in type_id_pairs")
246
+ return []
247
+
248
+ logger.debug(
249
+ f"Finding connected items for {len(entity_ids)} entities with depth {max_depth}"
250
+ )
251
+
252
+ # Build the VALUES clause for entity IDs
253
+ entity_id_values = ", ".join([str(i) for i in entity_ids])
142
254
 
143
- # Build the VALUES clause directly since SQLite doesn't handle parameterized IN well
255
+ # For compatibility with the old query, we still need this for filtering
144
256
  values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
145
257
 
146
- # Parameters for bindings
147
- params = {"max_depth": max_depth, "max_results": max_results}
258
+ # Parameters for bindings - include project_id for security filtering
259
+ params = {
260
+ "max_depth": max_depth,
261
+ "max_results": max_results,
262
+ "project_id": self.search_repository.project_id,
263
+ }
264
+
265
+ # Build date and timeframe filters conditionally based on since parameter
148
266
  if since:
149
267
  params["since_date"] = since.isoformat() # pyright: ignore
268
+ date_filter = "AND e.created_at >= :since_date"
269
+ relation_date_filter = "AND e_from.created_at >= :since_date"
270
+ timeframe_condition = "AND eg.relation_date >= :since_date"
271
+ else:
272
+ date_filter = ""
273
+ relation_date_filter = ""
274
+ timeframe_condition = ""
150
275
 
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 ""
276
+ # Add project filtering for security - ensure all entities and relations belong to the same project
277
+ project_filter = "AND e.project_id = :project_id"
278
+ relation_project_filter = "AND e_from.project_id = :project_id"
155
279
 
280
+ # Use a CTE that operates directly on entity and relation tables
281
+ # This avoids the overhead of the search_index virtual table
156
282
  query = text(f"""
157
- WITH RECURSIVE context_graph AS (
158
- -- Base case: seed items
283
+ WITH RECURSIVE entity_graph AS (
284
+ -- Base case: seed entities
159
285
  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,
286
+ e.id,
287
+ 'entity' as type,
288
+ e.title,
289
+ e.permalink,
290
+ e.file_path,
291
+ NULL as from_id,
292
+ NULL as to_id,
293
+ NULL as relation_type,
294
+ NULL as content,
295
+ NULL as category,
296
+ NULL as entity_id,
171
297
  0 as depth,
172
- id as root_id,
173
- created_at,
174
- created_at as relation_date,
298
+ e.id as root_id,
299
+ e.created_at,
300
+ e.created_at as relation_date,
175
301
  0 as is_incoming
176
- FROM search_index base
177
- WHERE (base.type, base.id) IN ({values})
302
+ FROM entity e
303
+ WHERE e.id IN ({entity_id_values})
178
304
  {date_filter}
305
+ {project_filter}
179
306
 
180
- UNION ALL -- Allow same paths at different depths
307
+ UNION ALL
181
308
 
182
- -- Get relations from current entities
183
- SELECT DISTINCT
309
+ -- Get relations from current entities
310
+ SELECT
184
311
  r.id,
185
- r.type,
186
- r.title,
187
- r.permalink,
188
- r.file_path,
312
+ 'relation' as type,
313
+ r.relation_type || ': ' || r.to_name as title,
314
+ -- Relation model doesn't have permalink column - we'll generate it at runtime
315
+ '' as permalink,
316
+ e_from.file_path,
189
317
  r.from_id,
190
318
  r.to_id,
191
319
  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}
320
+ NULL as content,
321
+ NULL as category,
322
+ NULL as entity_id,
323
+ eg.depth + 1,
324
+ eg.root_id,
325
+ e_from.created_at, -- Use the from_entity's created_at since relation has no timestamp
326
+ e_from.created_at as relation_date,
327
+ CASE WHEN r.from_id = eg.id THEN 0 ELSE 1 END as is_incoming
328
+ FROM entity_graph eg
329
+ JOIN relation r ON (
330
+ eg.type = 'entity' AND
331
+ (r.from_id = eg.id OR r.to_id = eg.id)
206
332
  )
207
- WHERE cg.depth < :max_depth
333
+ JOIN entity e_from ON (
334
+ r.from_id = e_from.id
335
+ {relation_date_filter}
336
+ {relation_project_filter}
337
+ )
338
+ LEFT JOIN entity e_to ON (r.to_id = e_to.id)
339
+ WHERE eg.depth < :max_depth
340
+ -- Ensure to_entity (if exists) also belongs to same project
341
+ AND (r.to_id IS NULL OR e_to.project_id = :project_id)
208
342
 
209
343
  UNION ALL
210
344
 
211
345
  -- Get entities connected by relations
212
- SELECT DISTINCT
346
+ SELECT
213
347
  e.id,
214
- e.type,
348
+ 'entity' as type,
215
349
  e.title,
216
- e.permalink,
350
+ CASE
351
+ WHEN e.permalink IS NULL THEN ''
352
+ ELSE e.permalink
353
+ END as permalink,
217
354
  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,
355
+ NULL as from_id,
356
+ NULL as to_id,
357
+ NULL as relation_type,
358
+ NULL as content,
359
+ NULL as category,
360
+ NULL as entity_id,
361
+ eg.depth + 1,
362
+ eg.root_id,
226
363
  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
364
+ eg.relation_date,
365
+ eg.is_incoming
366
+ FROM entity_graph eg
367
+ JOIN entity e ON (
368
+ eg.type = 'relation' AND
233
369
  e.id = CASE
234
- WHEN cg.is_incoming = 0 THEN cg.to_id -- Fixed entity lookup
235
- ELSE cg.from_id
370
+ WHEN eg.is_incoming = 0 THEN eg.to_id
371
+ ELSE eg.from_id
236
372
  END
237
- {related_date_filter}
373
+ {date_filter}
374
+ {project_filter}
238
375
  )
239
- WHERE cg.depth < :max_depth
376
+ WHERE eg.depth < :max_depth
377
+ -- Only include entities connected by relations within timeframe if specified
378
+ {timeframe_condition}
240
379
  )
241
380
  SELECT DISTINCT
242
381
  type,
@@ -253,12 +392,10 @@ class ContextService:
253
392
  MIN(depth) as depth,
254
393
  root_id,
255
394
  created_at
256
- FROM context_graph
395
+ FROM entity_graph
257
396
  WHERE (type, id) NOT IN ({values})
258
397
  GROUP BY
259
- type, id, title, permalink, from_id, to_id,
260
- relation_type, category, entity_id,
261
- root_id, created_at
398
+ type, id
262
399
  ORDER BY depth, type, id
263
400
  LIMIT :max_results
264
401
  """)