basic-memory 0.2.12__py3-none-any.whl → 0.16.1__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (149) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +63 -31
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +165 -28
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +28 -67
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +219 -14
  23. basic_memory/api/routers/search_router.py +21 -13
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +52 -1
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +26 -7
  41. basic_memory/cli/commands/import_chatgpt.py +83 -0
  42. basic_memory/cli/commands/import_claude_conversations.py +86 -0
  43. basic_memory/cli/commands/import_claude_projects.py +85 -0
  44. basic_memory/cli/commands/import_memory_json.py +35 -92
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +47 -30
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +13 -6
  50. basic_memory/config.py +481 -22
  51. basic_memory/db.py +192 -32
  52. basic_memory/deps.py +252 -22
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -14
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +437 -59
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +188 -23
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +57 -3
  115. basic_memory/schemas/response.py +9 -1
  116. basic_memory/schemas/search.py +33 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +251 -106
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +595 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +50 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +172 -34
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1176 -96
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +388 -28
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -203
  140. basic_memory/mcp/tools/knowledge.py +0 -56
  141. basic_memory/mcp/tools/memory.py +0 -151
  142. basic_memory/mcp/tools/notes.py +0 -122
  143. basic_memory/schemas/discovery.py +0 -28
  144. basic_memory/sync/file_change_scanner.py +0 -158
  145. basic_memory/sync/utils.py +0 -34
  146. basic_memory-0.2.12.dist-info/METADATA +0 -291
  147. basic_memory-0.2.12.dist-info/RECORD +0 -78
  148. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  149. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,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,
@@ -54,27 +90,46 @@ class ContextService:
54
90
  types: Optional[List[SearchItemType]] = None,
55
91
  depth: int = 1,
56
92
  since: Optional[datetime] = None,
57
- max_results: int = 10,
58
- ):
93
+ limit=10,
94
+ offset=0,
95
+ max_related: int = 10,
96
+ include_observations: bool = True,
97
+ ) -> ContextResult:
59
98
  """Build rich context from a memory:// URI."""
60
99
  logger.debug(
61
- f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' max_results: '{max_results}'"
100
+ f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
62
101
  )
63
102
 
103
+ normalized_path: Optional[str] = None
64
104
  if memory_url:
65
105
  path = memory_url_path(memory_url)
66
- # Pattern matching - use search
67
- if "*" in path:
68
- logger.debug(f"Pattern search for '{path}'")
69
- primary = await self.search_repository.search(permalink_match=path)
106
+ # Check for wildcards before normalization
107
+ has_wildcard = "*" in path
70
108
 
71
- # Direct lookup for exact path
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}'")
118
+ primary = await self.search_repository.search(
119
+ permalink_match=normalized_path, limit=limit, offset=offset
120
+ )
72
121
  else:
73
- logger.debug(f"Direct lookup for '{path}'")
74
- primary = await self.search_repository.search(permalink=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}'")
125
+ primary = await self.search_repository.search(
126
+ permalink=normalized_path, limit=limit, offset=offset
127
+ )
75
128
  else:
76
129
  logger.debug(f"Build context for '{types}'")
77
- primary = await self.search_repository.search(types=types, after_date=since)
130
+ primary = await self.search_repository.search(
131
+ search_item_types=types, after_date=since, limit=limit, offset=offset
132
+ )
78
133
 
79
134
  # Get type_id pairs for traversal
80
135
 
@@ -83,27 +138,81 @@ class ContextService:
83
138
 
84
139
  # Find related content
85
140
  related = await self.find_related(
86
- type_id_pairs, max_depth=depth, since=since, max_results=max_results
141
+ type_id_pairs, max_depth=depth, since=since, max_results=max_related
87
142
  )
88
143
  logger.debug(f"Found {len(related)} related results")
89
- for r in related:
90
- logger.debug(f"Found related {r.type}: {r.permalink}")
91
-
92
- # Build response
93
- return {
94
- "primary_results": primary,
95
- "related_results": related,
96
- "metadata": {
97
- "uri": memory_url_path(memory_url) if memory_url else None,
98
- "types": types if types else None,
99
- "depth": depth,
100
- "timeframe": since.isoformat() if since else None,
101
- "generated_at": datetime.now(timezone.utc).isoformat(),
102
- "matched_results": len(primary),
103
- "total_results": len(primary) + len(related),
104
- "total_relations": sum(1 for r in related if r.type == SearchItemType.RELATION),
105
- },
106
- }
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)
107
216
 
108
217
  async def find_related(
109
218
  self,
@@ -116,7 +225,6 @@ class ContextService:
116
225
 
117
226
  Uses recursive CTE to find:
118
227
  - Connected entities
119
- - Their observations
120
228
  - Relations that connect them
121
229
 
122
230
  Note on depth:
@@ -130,105 +238,144 @@ class ContextService:
130
238
  if not type_id_pairs:
131
239
  return []
132
240
 
133
- 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])
134
254
 
135
- # 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
136
256
  values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
137
257
 
138
- # Parameters for bindings
139
- 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
140
266
  if since:
141
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 = ""
142
275
 
143
- # Build date filter
144
- date_filter = "AND base.created_at >= :since_date" if since else ""
145
- r1_date_filter = "AND r.created_at >= :since_date" if since else ""
146
- 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"
147
279
 
280
+ # Use a CTE that operates directly on entity and relation tables
281
+ # This avoids the overhead of the search_index virtual table
148
282
  query = text(f"""
149
- WITH RECURSIVE context_graph AS (
150
- -- Base case: seed items
283
+ WITH RECURSIVE entity_graph AS (
284
+ -- Base case: seed entities
151
285
  SELECT
152
- id,
153
- type,
154
- title,
155
- permalink,
156
- file_path,
157
- from_id,
158
- to_id,
159
- relation_type,
160
- content,
161
- category,
162
- 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,
163
297
  0 as depth,
164
- id as root_id,
165
- created_at,
166
- created_at as relation_date,
298
+ e.id as root_id,
299
+ e.created_at,
300
+ e.created_at as relation_date,
167
301
  0 as is_incoming
168
- FROM search_index base
169
- WHERE (base.type, base.id) IN ({values})
302
+ FROM entity e
303
+ WHERE e.id IN ({entity_id_values})
170
304
  {date_filter}
305
+ {project_filter}
171
306
 
172
- UNION ALL -- Allow same paths at different depths
307
+ UNION ALL
173
308
 
174
- -- Get relations from current entities
175
- SELECT DISTINCT
309
+ -- Get relations from current entities
310
+ SELECT
176
311
  r.id,
177
- r.type,
178
- r.title,
179
- r.permalink,
180
- 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,
181
317
  r.from_id,
182
318
  r.to_id,
183
319
  r.relation_type,
184
- r.content,
185
- r.category,
186
- r.entity_id,
187
- cg.depth + 1,
188
- cg.root_id,
189
- r.created_at,
190
- r.created_at as relation_date,
191
- CASE WHEN r.from_id = cg.id THEN 0 ELSE 1 END as is_incoming
192
- FROM context_graph cg
193
- JOIN search_index r ON (
194
- cg.type = 'entity' AND
195
- r.type = 'relation' AND
196
- (r.from_id = cg.id OR r.to_id = cg.id)
197
- {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)
198
332
  )
199
- 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)
200
342
 
201
343
  UNION ALL
202
344
 
203
345
  -- Get entities connected by relations
204
- SELECT DISTINCT
346
+ SELECT
205
347
  e.id,
206
- e.type,
348
+ 'entity' as type,
207
349
  e.title,
208
- e.permalink,
350
+ CASE
351
+ WHEN e.permalink IS NULL THEN ''
352
+ ELSE e.permalink
353
+ END as permalink,
209
354
  e.file_path,
210
- e.from_id,
211
- e.to_id,
212
- e.relation_type,
213
- e.content,
214
- e.category,
215
- e.entity_id,
216
- cg.depth + 1, -- Increment depth for entities
217
- 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,
218
363
  e.created_at,
219
- cg.relation_date,
220
- cg.is_incoming
221
- FROM context_graph cg
222
- JOIN search_index e ON (
223
- cg.type = 'relation' AND
224
- 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
225
369
  e.id = CASE
226
- WHEN cg.is_incoming = 0 THEN cg.to_id -- Fixed entity lookup
227
- ELSE cg.from_id
370
+ WHEN eg.is_incoming = 0 THEN eg.to_id
371
+ ELSE eg.from_id
228
372
  END
229
- {related_date_filter}
373
+ {date_filter}
374
+ {project_filter}
230
375
  )
231
- 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}
232
379
  )
233
380
  SELECT DISTINCT
234
381
  type,
@@ -245,12 +392,10 @@ class ContextService:
245
392
  MIN(depth) as depth,
246
393
  root_id,
247
394
  created_at
248
- FROM context_graph
395
+ FROM entity_graph
249
396
  WHERE (type, id) NOT IN ({values})
250
397
  GROUP BY
251
- type, id, title, permalink, from_id, to_id,
252
- relation_type, category, entity_id,
253
- root_id, created_at
398
+ type, id
254
399
  ORDER BY depth, type, id
255
400
  LIMIT :max_results
256
401
  """)