basic-memory 0.12.2__py3-none-any.whl → 0.13.0__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 (117) hide show
  1. basic_memory/__init__.py +2 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  5. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
  6. basic_memory/api/app.py +43 -13
  7. basic_memory/api/routers/__init__.py +4 -2
  8. basic_memory/api/routers/directory_router.py +63 -0
  9. basic_memory/api/routers/importer_router.py +152 -0
  10. basic_memory/api/routers/knowledge_router.py +139 -37
  11. basic_memory/api/routers/management_router.py +78 -0
  12. basic_memory/api/routers/memory_router.py +6 -62
  13. basic_memory/api/routers/project_router.py +234 -0
  14. basic_memory/api/routers/prompt_router.py +260 -0
  15. basic_memory/api/routers/search_router.py +3 -21
  16. basic_memory/api/routers/utils.py +130 -0
  17. basic_memory/api/template_loader.py +292 -0
  18. basic_memory/cli/app.py +20 -21
  19. basic_memory/cli/commands/__init__.py +2 -1
  20. basic_memory/cli/commands/auth.py +136 -0
  21. basic_memory/cli/commands/db.py +3 -3
  22. basic_memory/cli/commands/import_chatgpt.py +31 -207
  23. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  24. basic_memory/cli/commands/import_claude_projects.py +33 -143
  25. basic_memory/cli/commands/import_memory_json.py +26 -83
  26. basic_memory/cli/commands/mcp.py +71 -18
  27. basic_memory/cli/commands/project.py +102 -70
  28. basic_memory/cli/commands/status.py +19 -9
  29. basic_memory/cli/commands/sync.py +44 -58
  30. basic_memory/cli/commands/tool.py +6 -6
  31. basic_memory/cli/main.py +1 -5
  32. basic_memory/config.py +143 -87
  33. basic_memory/db.py +6 -4
  34. basic_memory/deps.py +227 -30
  35. basic_memory/importers/__init__.py +27 -0
  36. basic_memory/importers/base.py +79 -0
  37. basic_memory/importers/chatgpt_importer.py +222 -0
  38. basic_memory/importers/claude_conversations_importer.py +172 -0
  39. basic_memory/importers/claude_projects_importer.py +148 -0
  40. basic_memory/importers/memory_json_importer.py +93 -0
  41. basic_memory/importers/utils.py +58 -0
  42. basic_memory/markdown/entity_parser.py +5 -2
  43. basic_memory/mcp/auth_provider.py +270 -0
  44. basic_memory/mcp/external_auth_provider.py +321 -0
  45. basic_memory/mcp/project_session.py +103 -0
  46. basic_memory/mcp/prompts/__init__.py +2 -0
  47. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  48. basic_memory/mcp/prompts/recent_activity.py +20 -4
  49. basic_memory/mcp/prompts/search.py +14 -140
  50. basic_memory/mcp/prompts/sync_status.py +116 -0
  51. basic_memory/mcp/prompts/utils.py +3 -3
  52. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  53. basic_memory/mcp/server.py +86 -13
  54. basic_memory/mcp/supabase_auth_provider.py +463 -0
  55. basic_memory/mcp/tools/__init__.py +24 -0
  56. basic_memory/mcp/tools/build_context.py +43 -8
  57. basic_memory/mcp/tools/canvas.py +17 -3
  58. basic_memory/mcp/tools/delete_note.py +168 -5
  59. basic_memory/mcp/tools/edit_note.py +303 -0
  60. basic_memory/mcp/tools/list_directory.py +154 -0
  61. basic_memory/mcp/tools/move_note.py +299 -0
  62. basic_memory/mcp/tools/project_management.py +332 -0
  63. basic_memory/mcp/tools/read_content.py +15 -6
  64. basic_memory/mcp/tools/read_note.py +28 -9
  65. basic_memory/mcp/tools/recent_activity.py +47 -16
  66. basic_memory/mcp/tools/search.py +189 -8
  67. basic_memory/mcp/tools/sync_status.py +254 -0
  68. basic_memory/mcp/tools/utils.py +184 -12
  69. basic_memory/mcp/tools/view_note.py +66 -0
  70. basic_memory/mcp/tools/write_note.py +24 -17
  71. basic_memory/models/__init__.py +3 -2
  72. basic_memory/models/knowledge.py +16 -4
  73. basic_memory/models/project.py +78 -0
  74. basic_memory/models/search.py +8 -5
  75. basic_memory/repository/__init__.py +2 -0
  76. basic_memory/repository/entity_repository.py +8 -3
  77. basic_memory/repository/observation_repository.py +35 -3
  78. basic_memory/repository/project_info_repository.py +3 -2
  79. basic_memory/repository/project_repository.py +85 -0
  80. basic_memory/repository/relation_repository.py +8 -2
  81. basic_memory/repository/repository.py +107 -15
  82. basic_memory/repository/search_repository.py +192 -54
  83. basic_memory/schemas/__init__.py +6 -0
  84. basic_memory/schemas/base.py +33 -5
  85. basic_memory/schemas/directory.py +30 -0
  86. basic_memory/schemas/importer.py +34 -0
  87. basic_memory/schemas/memory.py +84 -13
  88. basic_memory/schemas/project_info.py +112 -2
  89. basic_memory/schemas/prompt.py +90 -0
  90. basic_memory/schemas/request.py +56 -2
  91. basic_memory/schemas/search.py +1 -1
  92. basic_memory/services/__init__.py +2 -1
  93. basic_memory/services/context_service.py +208 -95
  94. basic_memory/services/directory_service.py +167 -0
  95. basic_memory/services/entity_service.py +399 -6
  96. basic_memory/services/exceptions.py +6 -0
  97. basic_memory/services/file_service.py +14 -15
  98. basic_memory/services/initialization.py +170 -66
  99. basic_memory/services/link_resolver.py +35 -12
  100. basic_memory/services/migration_service.py +168 -0
  101. basic_memory/services/project_service.py +671 -0
  102. basic_memory/services/search_service.py +77 -2
  103. basic_memory/services/sync_status_service.py +181 -0
  104. basic_memory/sync/background_sync.py +25 -0
  105. basic_memory/sync/sync_service.py +102 -21
  106. basic_memory/sync/watch_service.py +63 -39
  107. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  108. basic_memory/templates/prompts/search.hbs +101 -0
  109. basic_memory/utils.py +67 -17
  110. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/METADATA +26 -4
  111. basic_memory-0.13.0.dist-info/RECORD +138 -0
  112. basic_memory/api/routers/project_info_router.py +0 -274
  113. basic_memory/mcp/main.py +0 -24
  114. basic_memory-0.12.2.dist-info/RECORD +0 -100
  115. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
  116. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
  117. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.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,7 +93,8 @@ 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}'"
@@ -81,7 +118,7 @@ class ContextService:
81
118
  else:
82
119
  logger.debug(f"Build context for '{types}'")
83
120
  primary = await self.search_repository.search(
84
- entity_types=types, after_date=since, limit=limit, offset=offset
121
+ search_item_types=types, after_date=since, limit=limit, offset=offset
85
122
  )
86
123
 
87
124
  # Get type_id pairs for traversal
@@ -94,24 +131,78 @@ class ContextService:
94
131
  type_id_pairs, max_depth=depth, since=since, max_results=max_related
95
132
  )
96
133
  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
- }
134
+
135
+ # Collect entity IDs from primary and related results
136
+ entity_ids = []
137
+ for result in primary:
138
+ if result.type == SearchItemType.ENTITY.value:
139
+ entity_ids.append(result.id)
140
+
141
+ for result in related:
142
+ if result.type == SearchItemType.ENTITY.value:
143
+ entity_ids.append(result.id)
144
+
145
+ # Fetch observations for all entities if requested
146
+ observations_by_entity = {}
147
+ if include_observations and entity_ids:
148
+ # Use our observation repository to get observations for all entities at once
149
+ observations_by_entity = await self.observation_repository.find_by_entities(entity_ids)
150
+ logger.debug(f"Found observations for {len(observations_by_entity)} entities")
151
+
152
+ # Create metadata dataclass
153
+ metadata = ContextMetadata(
154
+ uri=memory_url_path(memory_url) if memory_url else None,
155
+ types=types,
156
+ depth=depth,
157
+ timeframe=since.isoformat() if since else None,
158
+ primary_count=len(primary),
159
+ related_count=len(related),
160
+ total_observations=sum(len(obs) for obs in observations_by_entity.values()),
161
+ total_relations=sum(1 for r in related if r.type == SearchItemType.RELATION),
162
+ )
163
+
164
+ # Build context results list directly with ContextResultItem objects
165
+ context_results = []
166
+
167
+ # For each primary result
168
+ for primary_item in primary:
169
+ # Find all related items with this primary item as root
170
+ related_to_primary = [r for r in related if r.root_id == primary_item.id]
171
+
172
+ # Get observations for this item if it's an entity
173
+ item_observations = []
174
+ if primary_item.type == SearchItemType.ENTITY.value and include_observations:
175
+ # Convert Observation models to ContextResultRows
176
+ for obs in observations_by_entity.get(primary_item.id, []):
177
+ item_observations.append(
178
+ ContextResultRow(
179
+ type="observation",
180
+ id=obs.id,
181
+ title=f"{obs.category}: {obs.content[:50]}...",
182
+ permalink=generate_permalink(
183
+ f"{primary_item.permalink}/observations/{obs.category}/{obs.content}"
184
+ ),
185
+ file_path=primary_item.file_path,
186
+ content=obs.content,
187
+ category=obs.category,
188
+ entity_id=primary_item.id,
189
+ depth=0,
190
+ root_id=primary_item.id,
191
+ created_at=primary_item.created_at, # created_at time from entity
192
+ )
193
+ )
194
+
195
+ # Create ContextResultItem directly
196
+ context_item = ContextResultItem(
197
+ primary_result=primary_item,
198
+ observations=item_observations,
199
+ related_results=related_to_primary,
200
+ )
201
+
202
+ context_results.append(context_item)
203
+
204
+ # Return the structured ContextResult
205
+ return ContextResult(results=context_results, metadata=metadata)
115
206
 
116
207
  async def find_related(
117
208
  self,
@@ -124,7 +215,6 @@ class ContextService:
124
215
 
125
216
  Uses recursive CTE to find:
126
217
  - Connected entities
127
- - Their observations
128
218
  - Relations that connect them
129
219
 
130
220
  Note on depth:
@@ -138,105 +228,130 @@ class ContextService:
138
228
  if not type_id_pairs:
139
229
  return []
140
230
 
141
- logger.debug(f"Finding connected items for {type_id_pairs} with depth {max_depth}")
231
+ # Extract entity IDs from type_id_pairs for the optimized query
232
+ entity_ids = [i for t, i in type_id_pairs if t == "entity"]
233
+
234
+ if not entity_ids:
235
+ logger.debug("No entity IDs found in type_id_pairs")
236
+ return []
237
+
238
+ logger.debug(
239
+ f"Finding connected items for {len(entity_ids)} entities with depth {max_depth}"
240
+ )
241
+
242
+ # Build the VALUES clause for entity IDs
243
+ entity_id_values = ", ".join([str(i) for i in entity_ids])
142
244
 
143
- # Build the VALUES clause directly since SQLite doesn't handle parameterized IN well
245
+ # For compatibility with the old query, we still need this for filtering
144
246
  values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
145
247
 
146
248
  # Parameters for bindings
147
249
  params = {"max_depth": max_depth, "max_results": max_results}
250
+
251
+ # Build date and timeframe filters conditionally based on since parameter
148
252
  if since:
149
253
  params["since_date"] = since.isoformat() # pyright: ignore
254
+ date_filter = "AND e.created_at >= :since_date"
255
+ relation_date_filter = "AND e_from.created_at >= :since_date"
256
+ timeframe_condition = "AND eg.relation_date >= :since_date"
257
+ else:
258
+ date_filter = ""
259
+ relation_date_filter = ""
260
+ timeframe_condition = ""
150
261
 
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
-
262
+ # Use a CTE that operates directly on entity and relation tables
263
+ # This avoids the overhead of the search_index virtual table
156
264
  query = text(f"""
157
- WITH RECURSIVE context_graph AS (
158
- -- Base case: seed items
265
+ WITH RECURSIVE entity_graph AS (
266
+ -- Base case: seed entities
159
267
  SELECT
160
- id,
161
- type,
162
- title,
163
- permalink,
164
- file_path,
165
- from_id,
166
- to_id,
167
- relation_type,
168
- content_snippet as content,
169
- category,
170
- entity_id,
268
+ e.id,
269
+ 'entity' as type,
270
+ e.title,
271
+ e.permalink,
272
+ e.file_path,
273
+ NULL as from_id,
274
+ NULL as to_id,
275
+ NULL as relation_type,
276
+ NULL as content,
277
+ NULL as category,
278
+ NULL as entity_id,
171
279
  0 as depth,
172
- id as root_id,
173
- created_at,
174
- created_at as relation_date,
280
+ e.id as root_id,
281
+ e.created_at,
282
+ e.created_at as relation_date,
175
283
  0 as is_incoming
176
- FROM search_index base
177
- WHERE (base.type, base.id) IN ({values})
284
+ FROM entity e
285
+ WHERE e.id IN ({entity_id_values})
178
286
  {date_filter}
179
287
 
180
- UNION ALL -- Allow same paths at different depths
288
+ UNION ALL
181
289
 
182
- -- Get relations from current entities
183
- SELECT DISTINCT
290
+ -- Get relations from current entities
291
+ SELECT
184
292
  r.id,
185
- r.type,
186
- r.title,
187
- r.permalink,
188
- r.file_path,
293
+ 'relation' as type,
294
+ r.relation_type || ': ' || r.to_name as title,
295
+ -- Relation model doesn't have permalink column - we'll generate it at runtime
296
+ '' as permalink,
297
+ e_from.file_path,
189
298
  r.from_id,
190
299
  r.to_id,
191
300
  r.relation_type,
192
- r.content_snippet as 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}
301
+ NULL as content,
302
+ NULL as category,
303
+ NULL as entity_id,
304
+ eg.depth + 1,
305
+ eg.root_id,
306
+ e_from.created_at, -- Use the from_entity's created_at since relation has no timestamp
307
+ e_from.created_at as relation_date,
308
+ CASE WHEN r.from_id = eg.id THEN 0 ELSE 1 END as is_incoming
309
+ FROM entity_graph eg
310
+ JOIN relation r ON (
311
+ eg.type = 'entity' AND
312
+ (r.from_id = eg.id OR r.to_id = eg.id)
206
313
  )
207
- WHERE cg.depth < :max_depth
314
+ JOIN entity e_from ON (
315
+ r.from_id = e_from.id
316
+ {relation_date_filter}
317
+ )
318
+ WHERE eg.depth < :max_depth
208
319
 
209
320
  UNION ALL
210
321
 
211
322
  -- Get entities connected by relations
212
- SELECT DISTINCT
323
+ SELECT
213
324
  e.id,
214
- e.type,
325
+ 'entity' as type,
215
326
  e.title,
216
- e.permalink,
327
+ CASE
328
+ WHEN e.permalink IS NULL THEN ''
329
+ ELSE e.permalink
330
+ END as permalink,
217
331
  e.file_path,
218
- e.from_id,
219
- e.to_id,
220
- e.relation_type,
221
- e.content_snippet as content,
222
- e.category,
223
- e.entity_id,
224
- cg.depth + 1, -- Increment depth for entities
225
- cg.root_id,
332
+ NULL as from_id,
333
+ NULL as to_id,
334
+ NULL as relation_type,
335
+ NULL as content,
336
+ NULL as category,
337
+ NULL as entity_id,
338
+ eg.depth + 1,
339
+ eg.root_id,
226
340
  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
341
+ eg.relation_date,
342
+ eg.is_incoming
343
+ FROM entity_graph eg
344
+ JOIN entity e ON (
345
+ eg.type = 'relation' AND
233
346
  e.id = CASE
234
- WHEN cg.is_incoming = 0 THEN cg.to_id -- Fixed entity lookup
235
- ELSE cg.from_id
347
+ WHEN eg.is_incoming = 0 THEN eg.to_id
348
+ ELSE eg.from_id
236
349
  END
237
- {related_date_filter}
350
+ {date_filter}
238
351
  )
239
- WHERE cg.depth < :max_depth
352
+ WHERE eg.depth < :max_depth
353
+ -- Only include entities connected by relations within timeframe if specified
354
+ {timeframe_condition}
240
355
  )
241
356
  SELECT DISTINCT
242
357
  type,
@@ -253,12 +368,10 @@ class ContextService:
253
368
  MIN(depth) as depth,
254
369
  root_id,
255
370
  created_at
256
- FROM context_graph
371
+ FROM entity_graph
257
372
  WHERE (type, id) NOT IN ({values})
258
373
  GROUP BY
259
- type, id, title, permalink, from_id, to_id,
260
- relation_type, category, entity_id,
261
- root_id, created_at
374
+ type, id
262
375
  ORDER BY depth, type, id
263
376
  LIMIT :max_results
264
377
  """)
@@ -0,0 +1,167 @@
1
+ """Directory service for managing file directories and tree structure."""
2
+
3
+ import fnmatch
4
+ import logging
5
+ import os
6
+ from typing import Dict, List, Optional
7
+
8
+ from basic_memory.repository import EntityRepository
9
+ from basic_memory.schemas.directory import DirectoryNode
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class DirectoryService:
15
+ """Service for working with directory trees."""
16
+
17
+ def __init__(self, entity_repository: EntityRepository):
18
+ """Initialize the directory service.
19
+
20
+ Args:
21
+ entity_repository: Directory repository for data access.
22
+ """
23
+ self.entity_repository = entity_repository
24
+
25
+ async def get_directory_tree(self) -> DirectoryNode:
26
+ """Build a hierarchical directory tree from indexed files."""
27
+
28
+ # Get all files from DB (flat list)
29
+ entity_rows = await self.entity_repository.find_all()
30
+
31
+ # Create a root directory node
32
+ root_node = DirectoryNode(name="Root", directory_path="/", type="directory")
33
+
34
+ # Map to store directory nodes by path for easy lookup
35
+ dir_map: Dict[str, DirectoryNode] = {root_node.directory_path: root_node}
36
+
37
+ # First pass: create all directory nodes
38
+ for file in entity_rows:
39
+ # Process directory path components
40
+ parts = [p for p in file.file_path.split("/") if p]
41
+
42
+ # Create directory structure
43
+ current_path = "/"
44
+ for i, part in enumerate(parts[:-1]): # Skip the filename
45
+ parent_path = current_path
46
+ # Build the directory path
47
+ current_path = (
48
+ f"{current_path}{part}" if current_path == "/" else f"{current_path}/{part}"
49
+ )
50
+
51
+ # Create directory node if it doesn't exist
52
+ if current_path not in dir_map:
53
+ dir_node = DirectoryNode(
54
+ name=part, directory_path=current_path, type="directory"
55
+ )
56
+ dir_map[current_path] = dir_node
57
+
58
+ # Add to parent's children
59
+ if parent_path in dir_map:
60
+ dir_map[parent_path].children.append(dir_node)
61
+
62
+ # Second pass: add file nodes to their parent directories
63
+ for file in entity_rows:
64
+ file_name = os.path.basename(file.file_path)
65
+ parent_dir = os.path.dirname(file.file_path)
66
+ directory_path = "/" if parent_dir == "" else f"/{parent_dir}"
67
+
68
+ # Create file node
69
+ file_node = DirectoryNode(
70
+ name=file_name,
71
+ file_path=file.file_path, # Original path from DB (no leading slash)
72
+ directory_path=f"/{file.file_path}", # Path with leading slash
73
+ type="file",
74
+ title=file.title,
75
+ permalink=file.permalink,
76
+ entity_id=file.id,
77
+ entity_type=file.entity_type,
78
+ content_type=file.content_type,
79
+ updated_at=file.updated_at,
80
+ )
81
+
82
+ # Add to parent directory's children
83
+ if directory_path in dir_map:
84
+ dir_map[directory_path].children.append(file_node)
85
+ else:
86
+ # If parent directory doesn't exist (should be rare), add to root
87
+ dir_map["/"].children.append(file_node) # pragma: no cover
88
+
89
+ # Return the root node with its children
90
+ return root_node
91
+
92
+ async def list_directory(
93
+ self,
94
+ dir_name: str = "/",
95
+ depth: int = 1,
96
+ file_name_glob: Optional[str] = None,
97
+ ) -> List[DirectoryNode]:
98
+ """List directory contents with filtering and depth control.
99
+
100
+ Args:
101
+ dir_name: Directory path to list (default: root "/")
102
+ depth: Recursion depth (1 = immediate children only)
103
+ file_name_glob: Glob pattern for filtering file names
104
+
105
+ Returns:
106
+ List of DirectoryNode objects matching the criteria
107
+ """
108
+ # Normalize directory path
109
+ if not dir_name.startswith("/"):
110
+ dir_name = f"/{dir_name}"
111
+ if dir_name != "/" and dir_name.endswith("/"):
112
+ dir_name = dir_name.rstrip("/")
113
+
114
+ # Get the full directory tree
115
+ root_tree = await self.get_directory_tree()
116
+
117
+ # Find the target directory node
118
+ target_node = self._find_directory_node(root_tree, dir_name)
119
+ if not target_node:
120
+ return []
121
+
122
+ # Collect nodes with depth and glob filtering
123
+ result = []
124
+ self._collect_nodes_recursive(target_node, result, depth, file_name_glob, 0)
125
+
126
+ return result
127
+
128
+ def _find_directory_node(
129
+ self, root: DirectoryNode, target_path: str
130
+ ) -> Optional[DirectoryNode]:
131
+ """Find a directory node by path in the tree."""
132
+ if root.directory_path == target_path:
133
+ return root
134
+
135
+ for child in root.children:
136
+ if child.type == "directory":
137
+ found = self._find_directory_node(child, target_path)
138
+ if found:
139
+ return found
140
+
141
+ return None
142
+
143
+ def _collect_nodes_recursive(
144
+ self,
145
+ node: DirectoryNode,
146
+ result: List[DirectoryNode],
147
+ max_depth: int,
148
+ file_name_glob: Optional[str],
149
+ current_depth: int,
150
+ ) -> None:
151
+ """Recursively collect nodes with depth and glob filtering."""
152
+ if current_depth >= max_depth:
153
+ return
154
+
155
+ for child in node.children:
156
+ # Apply glob filtering
157
+ if file_name_glob and not fnmatch.fnmatch(child.name, file_name_glob):
158
+ continue
159
+
160
+ # Add the child to results
161
+ result.append(child)
162
+
163
+ # Recurse into subdirectories if we haven't reached max depth
164
+ if child.type == "directory" and current_depth < max_depth:
165
+ self._collect_nodes_recursive(
166
+ child, result, max_depth, file_name_glob, current_depth + 1
167
+ )