basic-memory 0.12.3__py3-none-any.whl → 0.13.0b1__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.
- basic_memory/__init__.py +7 -1
- basic_memory/alembic/env.py +1 -1
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -5
- basic_memory/api/app.py +43 -13
- basic_memory/api/routers/__init__.py +4 -2
- basic_memory/api/routers/directory_router.py +63 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +127 -38
- basic_memory/api/routers/management_router.py +78 -0
- basic_memory/api/routers/memory_router.py +4 -59
- basic_memory/api/routers/project_router.py +230 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/search_router.py +3 -21
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +20 -21
- basic_memory/cli/commands/__init__.py +2 -1
- basic_memory/cli/commands/auth.py +136 -0
- basic_memory/cli/commands/db.py +3 -3
- basic_memory/cli/commands/import_chatgpt.py +31 -207
- basic_memory/cli/commands/import_claude_conversations.py +16 -142
- basic_memory/cli/commands/import_claude_projects.py +33 -143
- basic_memory/cli/commands/import_memory_json.py +26 -83
- basic_memory/cli/commands/mcp.py +71 -18
- basic_memory/cli/commands/project.py +99 -67
- basic_memory/cli/commands/status.py +19 -9
- basic_memory/cli/commands/sync.py +44 -58
- basic_memory/cli/main.py +1 -5
- basic_memory/config.py +145 -88
- basic_memory/db.py +6 -4
- basic_memory/deps.py +227 -30
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +222 -0
- basic_memory/importers/claude_conversations_importer.py +172 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +93 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +5 -2
- basic_memory/mcp/auth_provider.py +270 -0
- basic_memory/mcp/external_auth_provider.py +321 -0
- basic_memory/mcp/project_session.py +103 -0
- basic_memory/mcp/prompts/continue_conversation.py +18 -68
- basic_memory/mcp/prompts/recent_activity.py +19 -3
- basic_memory/mcp/prompts/search.py +14 -140
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/{tools → resources}/project_info.py +6 -2
- basic_memory/mcp/server.py +82 -8
- basic_memory/mcp/supabase_auth_provider.py +463 -0
- basic_memory/mcp/tools/__init__.py +20 -0
- basic_memory/mcp/tools/build_context.py +11 -1
- basic_memory/mcp/tools/canvas.py +15 -2
- basic_memory/mcp/tools/delete_note.py +12 -4
- basic_memory/mcp/tools/edit_note.py +297 -0
- basic_memory/mcp/tools/list_directory.py +154 -0
- basic_memory/mcp/tools/move_note.py +87 -0
- basic_memory/mcp/tools/project_management.py +300 -0
- basic_memory/mcp/tools/read_content.py +15 -6
- basic_memory/mcp/tools/read_note.py +17 -5
- basic_memory/mcp/tools/recent_activity.py +11 -2
- basic_memory/mcp/tools/search.py +10 -1
- basic_memory/mcp/tools/utils.py +137 -12
- basic_memory/mcp/tools/write_note.py +11 -15
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +16 -4
- basic_memory/models/project.py +80 -0
- basic_memory/models/search.py +8 -5
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +8 -3
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +3 -2
- basic_memory/repository/project_repository.py +85 -0
- basic_memory/repository/relation_repository.py +8 -2
- basic_memory/repository/repository.py +107 -15
- basic_memory/repository/search_repository.py +87 -27
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +34 -0
- basic_memory/schemas/memory.py +26 -12
- basic_memory/schemas/project_info.py +112 -2
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/search.py +1 -1
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +208 -95
- basic_memory/services/directory_service.py +167 -0
- basic_memory/services/entity_service.py +385 -5
- basic_memory/services/exceptions.py +6 -0
- basic_memory/services/file_service.py +14 -15
- basic_memory/services/initialization.py +144 -67
- basic_memory/services/link_resolver.py +16 -8
- basic_memory/services/project_service.py +548 -0
- basic_memory/services/search_service.py +77 -2
- basic_memory/sync/background_sync.py +25 -0
- basic_memory/sync/sync_service.py +10 -9
- basic_memory/sync/watch_service.py +63 -39
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/METADATA +23 -1
- basic_memory-0.13.0b1.dist-info/RECORD +132 -0
- basic_memory/api/routers/project_info_router.py +0 -274
- basic_memory/mcp/main.py +0 -24
- basic_memory-0.12.3.dist-info/RECORD +0 -100
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/WHEEL +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.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.
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
152
|
-
|
|
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
|
|
158
|
-
-- Base case: seed
|
|
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
|
-
|
|
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
|
|
177
|
-
WHERE
|
|
284
|
+
FROM entity e
|
|
285
|
+
WHERE e.id IN ({entity_id_values})
|
|
178
286
|
{date_filter}
|
|
179
287
|
|
|
180
|
-
UNION ALL
|
|
288
|
+
UNION ALL
|
|
181
289
|
|
|
182
|
-
-- Get relations from current entities
|
|
183
|
-
SELECT
|
|
290
|
+
-- Get relations from current entities
|
|
291
|
+
SELECT
|
|
184
292
|
r.id,
|
|
185
|
-
|
|
186
|
-
r.title,
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
CASE WHEN r.from_id =
|
|
200
|
-
FROM
|
|
201
|
-
JOIN
|
|
202
|
-
|
|
203
|
-
r.
|
|
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
|
-
|
|
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
|
|
323
|
+
SELECT
|
|
213
324
|
e.id,
|
|
214
|
-
|
|
325
|
+
'entity' as type,
|
|
215
326
|
e.title,
|
|
216
|
-
|
|
327
|
+
CASE
|
|
328
|
+
WHEN e.permalink IS NULL THEN ''
|
|
329
|
+
ELSE e.permalink
|
|
330
|
+
END as permalink,
|
|
217
331
|
e.file_path,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
FROM
|
|
230
|
-
JOIN
|
|
231
|
-
|
|
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
|
|
235
|
-
ELSE
|
|
347
|
+
WHEN eg.is_incoming = 0 THEN eg.to_id
|
|
348
|
+
ELSE eg.from_id
|
|
236
349
|
END
|
|
237
|
-
{
|
|
350
|
+
{date_filter}
|
|
238
351
|
)
|
|
239
|
-
WHERE
|
|
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
|
|
371
|
+
FROM entity_graph
|
|
257
372
|
WHERE (type, id) NOT IN ({values})
|
|
258
373
|
GROUP BY
|
|
259
|
-
type, 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
|
+
)
|