basic-memory 0.1.0__py3-none-any.whl → 0.1.2__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 +1 -1
- basic_memory/alembic/README +1 -0
- basic_memory/alembic/env.py +75 -0
- basic_memory/alembic/migrations.py +29 -0
- basic_memory/alembic/script.py.mako +26 -0
- basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
- basic_memory/api/__init__.py +2 -1
- basic_memory/api/app.py +26 -24
- basic_memory/api/routers/knowledge_router.py +28 -26
- basic_memory/api/routers/memory_router.py +17 -11
- basic_memory/api/routers/search_router.py +6 -12
- basic_memory/cli/__init__.py +1 -1
- basic_memory/cli/app.py +0 -1
- basic_memory/cli/commands/__init__.py +3 -3
- basic_memory/cli/commands/db.py +25 -0
- basic_memory/cli/commands/import_memory_json.py +35 -31
- basic_memory/cli/commands/mcp.py +20 -0
- basic_memory/cli/commands/status.py +10 -6
- basic_memory/cli/commands/sync.py +5 -56
- basic_memory/cli/main.py +5 -39
- basic_memory/config.py +3 -3
- basic_memory/db.py +19 -21
- basic_memory/deps.py +3 -4
- basic_memory/file_utils.py +36 -35
- basic_memory/markdown/entity_parser.py +13 -30
- basic_memory/markdown/markdown_processor.py +7 -7
- basic_memory/markdown/plugins.py +109 -123
- basic_memory/markdown/schemas.py +7 -8
- basic_memory/markdown/utils.py +70 -121
- basic_memory/mcp/__init__.py +1 -1
- basic_memory/mcp/async_client.py +0 -2
- basic_memory/mcp/server.py +3 -27
- basic_memory/mcp/tools/__init__.py +5 -3
- basic_memory/mcp/tools/knowledge.py +2 -2
- basic_memory/mcp/tools/memory.py +8 -4
- basic_memory/mcp/tools/search.py +2 -1
- basic_memory/mcp/tools/utils.py +1 -1
- basic_memory/models/__init__.py +1 -2
- basic_memory/models/base.py +3 -3
- basic_memory/models/knowledge.py +23 -60
- basic_memory/models/search.py +1 -1
- basic_memory/repository/__init__.py +5 -3
- basic_memory/repository/entity_repository.py +34 -98
- basic_memory/repository/relation_repository.py +0 -7
- basic_memory/repository/repository.py +2 -39
- basic_memory/repository/search_repository.py +21 -24
- basic_memory/schemas/__init__.py +4 -4
- basic_memory/schemas/base.py +21 -62
- basic_memory/schemas/delete.py +2 -3
- basic_memory/schemas/discovery.py +4 -1
- basic_memory/schemas/memory.py +12 -13
- basic_memory/schemas/request.py +4 -23
- basic_memory/schemas/response.py +10 -9
- basic_memory/schemas/search.py +4 -7
- basic_memory/services/__init__.py +2 -7
- basic_memory/services/context_service.py +116 -110
- basic_memory/services/entity_service.py +25 -62
- basic_memory/services/exceptions.py +1 -0
- basic_memory/services/file_service.py +73 -109
- basic_memory/services/link_resolver.py +9 -9
- basic_memory/services/search_service.py +22 -15
- basic_memory/services/service.py +3 -24
- basic_memory/sync/__init__.py +2 -2
- basic_memory/sync/file_change_scanner.py +3 -7
- basic_memory/sync/sync_service.py +38 -38
- basic_memory/sync/utils.py +6 -38
- basic_memory/sync/watch_service.py +26 -5
- basic_memory/utils.py +42 -33
- {basic_memory-0.1.0.dist-info → basic_memory-0.1.2.dist-info}/METADATA +2 -7
- basic_memory-0.1.2.dist-info/RECORD +78 -0
- basic_memory/cli/commands/init.py +0 -38
- basic_memory/mcp/main.py +0 -21
- basic_memory/mcp/tools/ai_edit.py +0 -84
- basic_memory/services/database_service.py +0 -158
- basic_memory-0.1.0.dist-info/RECORD +0 -75
- {basic_memory-0.1.0.dist-info → basic_memory-0.1.2.dist-info}/WHEEL +0 -0
- {basic_memory-0.1.0.dist-info → basic_memory-0.1.2.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.1.0.dist-info → basic_memory-0.1.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Repository for managing entities in the knowledge graph."""
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional, Sequence, Union
|
|
4
5
|
|
|
5
|
-
from sqlalchemy import select, or_, asc
|
|
6
6
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
7
7
|
from sqlalchemy.orm import selectinload
|
|
8
8
|
from sqlalchemy.orm.interfaces import LoaderOption
|
|
@@ -12,109 +12,57 @@ from basic_memory.repository.repository import Repository
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class EntityRepository(Repository[Entity]):
|
|
15
|
-
"""Repository for Entity model.
|
|
15
|
+
"""Repository for Entity model.
|
|
16
|
+
|
|
17
|
+
Note: All file paths are stored as strings in the database. Convert Path objects
|
|
18
|
+
to strings before passing to repository methods.
|
|
19
|
+
"""
|
|
16
20
|
|
|
17
21
|
def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
|
|
18
22
|
"""Initialize with session maker."""
|
|
19
23
|
super().__init__(session_maker, Entity)
|
|
20
24
|
|
|
21
25
|
async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
|
|
22
|
-
"""Get entity by permalink.
|
|
26
|
+
"""Get entity by permalink.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
permalink: Unique identifier for the entity
|
|
30
|
+
"""
|
|
23
31
|
query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options())
|
|
24
32
|
return await self.find_one(query)
|
|
25
33
|
|
|
26
34
|
async def get_by_title(self, title: str) -> Optional[Entity]:
|
|
27
|
-
"""Get entity by title.
|
|
28
|
-
query = self.select().where(Entity.title == title).options(*self.get_load_options())
|
|
29
|
-
return await self.find_one(query)
|
|
35
|
+
"""Get entity by title.
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
Args:
|
|
38
|
+
title: Title of the entity to find
|
|
39
|
+
"""
|
|
40
|
+
query = self.select().where(Entity.title == title).options(*self.get_load_options())
|
|
34
41
|
return await self.find_one(query)
|
|
35
42
|
|
|
36
|
-
async def
|
|
37
|
-
|
|
38
|
-
entity_type: Optional[str] = None,
|
|
39
|
-
sort_by: Optional[str] = "updated_at",
|
|
40
|
-
include_related: bool = False,
|
|
41
|
-
) -> Sequence[Entity]:
|
|
42
|
-
"""List all entities, optionally filtered by type and sorted."""
|
|
43
|
-
query = self.select()
|
|
44
|
-
|
|
45
|
-
# Always load base relations
|
|
46
|
-
query = query.options(*self.get_load_options())
|
|
47
|
-
|
|
48
|
-
# Apply filters
|
|
49
|
-
if entity_type:
|
|
50
|
-
# When include_related is True, get both:
|
|
51
|
-
# 1. Entities of the requested type
|
|
52
|
-
# 2. Entities that have relations with entities of the requested type
|
|
53
|
-
if include_related:
|
|
54
|
-
query = query.where(
|
|
55
|
-
or_(
|
|
56
|
-
Entity.entity_type == entity_type,
|
|
57
|
-
Entity.outgoing_relations.any(
|
|
58
|
-
Relation.to_entity.has(entity_type=entity_type)
|
|
59
|
-
),
|
|
60
|
-
Entity.incoming_relations.any(
|
|
61
|
-
Relation.from_entity.has(entity_type=entity_type)
|
|
62
|
-
),
|
|
63
|
-
)
|
|
64
|
-
)
|
|
65
|
-
else:
|
|
66
|
-
query = query.where(Entity.entity_type == entity_type)
|
|
67
|
-
|
|
68
|
-
# Apply sorting
|
|
69
|
-
if sort_by:
|
|
70
|
-
sort_field = getattr(Entity, sort_by, Entity.updated_at)
|
|
71
|
-
query = query.order_by(asc(sort_field))
|
|
72
|
-
|
|
73
|
-
result = await self.execute_query(query)
|
|
74
|
-
return list(result.scalars().all())
|
|
75
|
-
|
|
76
|
-
async def get_entity_types(self) -> List[str]:
|
|
77
|
-
"""Get list of distinct entity types."""
|
|
78
|
-
query = select(Entity.entity_type).distinct()
|
|
79
|
-
|
|
80
|
-
result = await self.execute_query(query, use_query_options=False)
|
|
81
|
-
return list(result.scalars().all())
|
|
82
|
-
|
|
83
|
-
async def search(self, query_str: str) -> List[Entity]:
|
|
84
|
-
"""
|
|
85
|
-
Search for entities.
|
|
43
|
+
async def get_by_file_path(self, file_path: Union[Path, str]) -> Optional[Entity]:
|
|
44
|
+
"""Get entity by file_path.
|
|
86
45
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
- Entity types
|
|
90
|
-
- Entity descriptions
|
|
91
|
-
- Associated Observations content
|
|
46
|
+
Args:
|
|
47
|
+
file_path: Path to the entity file (will be converted to string internally)
|
|
92
48
|
"""
|
|
93
|
-
search_term = f"%{query_str}%"
|
|
94
49
|
query = (
|
|
95
50
|
self.select()
|
|
96
|
-
.where(
|
|
97
|
-
or_(
|
|
98
|
-
Entity.title.ilike(search_term),
|
|
99
|
-
Entity.entity_type.ilike(search_term),
|
|
100
|
-
Entity.summary.ilike(search_term),
|
|
101
|
-
Entity.observations.any(Observation.content.ilike(search_term)),
|
|
102
|
-
)
|
|
103
|
-
)
|
|
51
|
+
.where(Entity.file_path == str(file_path))
|
|
104
52
|
.options(*self.get_load_options())
|
|
105
53
|
)
|
|
106
|
-
|
|
107
|
-
return list(result.scalars().all())
|
|
54
|
+
return await self.find_one(query)
|
|
108
55
|
|
|
109
|
-
async def
|
|
110
|
-
"""Delete
|
|
111
|
-
return await self.delete_by_fields(doc_id=doc_id)
|
|
56
|
+
async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool:
|
|
57
|
+
"""Delete entity with the provided file_path.
|
|
112
58
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
59
|
+
Args:
|
|
60
|
+
file_path: Path to the entity file (will be converted to string internally)
|
|
61
|
+
"""
|
|
62
|
+
return await self.delete_by_fields(file_path=str(file_path))
|
|
116
63
|
|
|
117
64
|
def get_load_options(self) -> List[LoaderOption]:
|
|
65
|
+
"""Get SQLAlchemy loader options for eager loading relationships."""
|
|
118
66
|
return [
|
|
119
67
|
selectinload(Entity.observations).selectinload(Observation.entity),
|
|
120
68
|
# Load from_relations and both entities for each relation
|
|
@@ -126,8 +74,11 @@ class EntityRepository(Repository[Entity]):
|
|
|
126
74
|
]
|
|
127
75
|
|
|
128
76
|
async def find_by_permalinks(self, permalinks: List[str]) -> Sequence[Entity]:
|
|
129
|
-
"""Find multiple entities by their permalink.
|
|
77
|
+
"""Find multiple entities by their permalink.
|
|
130
78
|
|
|
79
|
+
Args:
|
|
80
|
+
permalinks: List of permalink strings to find
|
|
81
|
+
"""
|
|
131
82
|
# Handle empty input explicitly
|
|
132
83
|
if not permalinks:
|
|
133
84
|
return []
|
|
@@ -139,18 +90,3 @@ class EntityRepository(Repository[Entity]):
|
|
|
139
90
|
|
|
140
91
|
result = await self.execute_query(query)
|
|
141
92
|
return list(result.scalars().all())
|
|
142
|
-
|
|
143
|
-
async def delete_by_permalinks(self, permalinks: List[str]) -> int:
|
|
144
|
-
"""Delete multiple entities by permalink."""
|
|
145
|
-
|
|
146
|
-
# Handle empty input explicitly
|
|
147
|
-
if not permalinks:
|
|
148
|
-
return 0
|
|
149
|
-
|
|
150
|
-
# Find matching entities
|
|
151
|
-
entities = await self.find_by_permalinks(permalinks)
|
|
152
|
-
if not entities:
|
|
153
|
-
return 0
|
|
154
|
-
|
|
155
|
-
# Use existing delete_by_ids
|
|
156
|
-
return await self.delete_by_ids([entity.id for entity in entities])
|
|
@@ -40,12 +40,6 @@ class RelationRepository(Repository[Relation]):
|
|
|
40
40
|
)
|
|
41
41
|
return await self.find_one(query)
|
|
42
42
|
|
|
43
|
-
async def find_by_entity(self, from_entity_id: int) -> Sequence[Relation]:
|
|
44
|
-
"""Find all relations from a specific entity."""
|
|
45
|
-
query = select(Relation).filter(Relation.from_id == from_entity_id)
|
|
46
|
-
result = await self.execute_query(query)
|
|
47
|
-
return result.scalars().all()
|
|
48
|
-
|
|
49
43
|
async def find_by_entities(self, from_id: int, to_id: int) -> Sequence[Relation]:
|
|
50
44
|
"""Find all relations between two entities."""
|
|
51
45
|
query = select(Relation).where((Relation.from_id == from_id) & (Relation.to_id == to_id))
|
|
@@ -73,6 +67,5 @@ class RelationRepository(Repository[Relation]):
|
|
|
73
67
|
result = await self.execute_query(query)
|
|
74
68
|
return result.scalars().all()
|
|
75
69
|
|
|
76
|
-
|
|
77
70
|
def get_load_options(self) -> List[LoaderOption]:
|
|
78
71
|
return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Base repository implementation."""
|
|
2
2
|
|
|
3
|
-
from datetime import datetime
|
|
4
3
|
from typing import Type, Optional, Any, Sequence, TypeVar, List
|
|
5
4
|
|
|
6
5
|
from loguru import logger
|
|
@@ -98,13 +97,6 @@ class Repository[T: Base]:
|
|
|
98
97
|
entities = (self.Model,)
|
|
99
98
|
return select(*entities)
|
|
100
99
|
|
|
101
|
-
async def refresh(self, instance: T, relationships: list[str] | None = None) -> None:
|
|
102
|
-
"""Refresh instance and optionally specified relationships."""
|
|
103
|
-
logger.debug(f"Refreshing {self.Model.__name__} instance: {getattr(instance, 'id', None)}")
|
|
104
|
-
async with db.scoped_session(self.session_maker) as session:
|
|
105
|
-
await session.refresh(instance, relationships or [])
|
|
106
|
-
logger.debug(f"Refreshed relationships: {relationships}")
|
|
107
|
-
|
|
108
100
|
async def find_all(self, skip: int = 0, limit: Optional[int] = 0) -> Sequence[T]:
|
|
109
101
|
"""Fetch records from the database with pagination."""
|
|
110
102
|
logger.debug(f"Finding all {self.Model.__name__} (skip={skip}, limit={limit})")
|
|
@@ -149,35 +141,6 @@ class Repository[T: Base]:
|
|
|
149
141
|
logger.debug(f"No {self.Model.__name__} found")
|
|
150
142
|
return entity
|
|
151
143
|
|
|
152
|
-
async def find_modified_since(self, since: datetime) -> Sequence[T]:
|
|
153
|
-
"""Find all records modified since the given timestamp.
|
|
154
|
-
|
|
155
|
-
This method assumes the model has an updated_at column. Override
|
|
156
|
-
in subclasses if a different column should be used.
|
|
157
|
-
|
|
158
|
-
Args:
|
|
159
|
-
since: Datetime to search from
|
|
160
|
-
|
|
161
|
-
Returns:
|
|
162
|
-
Sequence of records modified since the timestamp
|
|
163
|
-
"""
|
|
164
|
-
logger.debug(f"Finding {self.Model.__name__} modified since: {since}")
|
|
165
|
-
|
|
166
|
-
if not hasattr(self.Model, "updated_at"):
|
|
167
|
-
raise AttributeError(f"{self.Model.__name__} does not have updated_at column")
|
|
168
|
-
|
|
169
|
-
query = (
|
|
170
|
-
select(self.Model)
|
|
171
|
-
.filter(self.Model.updated_at >= since)
|
|
172
|
-
.options(*self.get_load_options())
|
|
173
|
-
)
|
|
174
|
-
|
|
175
|
-
async with db.scoped_session(self.session_maker) as session:
|
|
176
|
-
result = await session.execute(query)
|
|
177
|
-
items = result.scalars().all()
|
|
178
|
-
logger.debug(f"Found {len(items)} modified {self.Model.__name__} records")
|
|
179
|
-
return items
|
|
180
|
-
|
|
181
144
|
async def create(self, data: dict) -> T:
|
|
182
145
|
"""Create a new record from a model instance."""
|
|
183
146
|
logger.debug(f"Creating {self.Model.__name__} from entity_data: {data}")
|
|
@@ -223,11 +186,11 @@ class Repository[T: Base]:
|
|
|
223
186
|
for key, value in entity_data.items():
|
|
224
187
|
if key in self.valid_columns:
|
|
225
188
|
setattr(entity, key, value)
|
|
226
|
-
|
|
189
|
+
|
|
227
190
|
elif isinstance(entity_data, self.Model):
|
|
228
191
|
for column in self.Model.__table__.columns.keys():
|
|
229
192
|
setattr(entity, column, getattr(entity_data, column))
|
|
230
|
-
|
|
193
|
+
|
|
231
194
|
await session.flush() # Make sure changes are flushed
|
|
232
195
|
await session.refresh(entity) # Refresh
|
|
233
196
|
|
|
@@ -21,6 +21,8 @@ class SearchIndexRow:
|
|
|
21
21
|
|
|
22
22
|
id: int
|
|
23
23
|
type: str
|
|
24
|
+
permalink: str
|
|
25
|
+
file_path: str
|
|
24
26
|
metadata: Optional[dict] = None
|
|
25
27
|
|
|
26
28
|
# date values
|
|
@@ -30,13 +32,9 @@ class SearchIndexRow:
|
|
|
30
32
|
# assigned in result
|
|
31
33
|
score: Optional[float] = None
|
|
32
34
|
|
|
33
|
-
# Common fields
|
|
34
|
-
permalink: Optional[str] = None
|
|
35
|
-
file_path: Optional[str] = None
|
|
36
|
-
|
|
37
35
|
# Type-specific fields
|
|
38
|
-
title: Optional[
|
|
39
|
-
content: Optional[
|
|
36
|
+
title: Optional[str] = None # entity
|
|
37
|
+
content: Optional[str] = None # entity, observation
|
|
40
38
|
entity_id: Optional[int] = None # observations
|
|
41
39
|
category: Optional[str] = None # observations
|
|
42
40
|
from_id: Optional[int] = None # relations
|
|
@@ -70,6 +68,8 @@ class SearchRepository:
|
|
|
70
68
|
|
|
71
69
|
async def init_search_index(self):
|
|
72
70
|
"""Create or recreate the search index."""
|
|
71
|
+
|
|
72
|
+
logger.info("Initializing search index")
|
|
73
73
|
async with db.scoped_session(self.session_maker) as session:
|
|
74
74
|
await session.execute(CREATE_SEARCH_INDEX)
|
|
75
75
|
await session.commit()
|
|
@@ -79,8 +79,8 @@ class SearchRepository:
|
|
|
79
79
|
For FTS5, special characters and phrases need to be quoted to be treated as a single token.
|
|
80
80
|
"""
|
|
81
81
|
# List of special characters that need quoting
|
|
82
|
-
special_chars = [
|
|
83
|
-
|
|
82
|
+
special_chars = ["/", "*", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
|
|
83
|
+
|
|
84
84
|
# Check if term contains any special characters
|
|
85
85
|
if any(c in term for c in special_chars):
|
|
86
86
|
# If the term already contains quotes, escape them
|
|
@@ -94,16 +94,16 @@ class SearchRepository:
|
|
|
94
94
|
permalink: Optional[str] = None,
|
|
95
95
|
permalink_match: Optional[str] = None,
|
|
96
96
|
title: Optional[str] = None,
|
|
97
|
-
types: List[SearchItemType] = None,
|
|
97
|
+
types: Optional[List[SearchItemType]] = None,
|
|
98
98
|
after_date: Optional[datetime] = None,
|
|
99
|
-
entity_types: List[str] = None,
|
|
99
|
+
entity_types: Optional[List[str]] = None,
|
|
100
100
|
limit: int = 10,
|
|
101
101
|
) -> List[SearchIndexRow]:
|
|
102
102
|
"""Search across all indexed content with fuzzy matching."""
|
|
103
103
|
conditions = []
|
|
104
104
|
params = {}
|
|
105
105
|
order_by_clause = ""
|
|
106
|
-
|
|
106
|
+
|
|
107
107
|
# Handle text search for title and content
|
|
108
108
|
if search_text:
|
|
109
109
|
search_text = self._quote_search_term(search_text.lower().strip())
|
|
@@ -125,7 +125,7 @@ class SearchRepository:
|
|
|
125
125
|
if permalink_match:
|
|
126
126
|
params["permalink"] = self._quote_search_term(permalink_match)
|
|
127
127
|
conditions.append("permalink MATCH :permalink")
|
|
128
|
-
|
|
128
|
+
|
|
129
129
|
# Handle type filter
|
|
130
130
|
if types:
|
|
131
131
|
type_list = ", ".join(f"'{t.value}'" for t in types)
|
|
@@ -140,13 +140,13 @@ class SearchRepository:
|
|
|
140
140
|
if after_date:
|
|
141
141
|
params["after_date"] = after_date
|
|
142
142
|
conditions.append("datetime(created_at) > datetime(:after_date)")
|
|
143
|
-
|
|
143
|
+
|
|
144
144
|
# order by most recent first
|
|
145
145
|
order_by_clause = ", updated_at DESC"
|
|
146
146
|
|
|
147
147
|
# set limit on search query
|
|
148
148
|
params["limit"] = limit
|
|
149
|
-
|
|
149
|
+
|
|
150
150
|
# Build WHERE clause
|
|
151
151
|
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
|
152
152
|
|
|
@@ -173,7 +173,7 @@ class SearchRepository:
|
|
|
173
173
|
LIMIT :limit
|
|
174
174
|
"""
|
|
175
175
|
|
|
176
|
-
#logger.debug(f"Search {sql} params: {params}")
|
|
176
|
+
# logger.debug(f"Search {sql} params: {params}")
|
|
177
177
|
async with db.scoped_session(self.session_maker) as session:
|
|
178
178
|
result = await session.execute(text(sql), params)
|
|
179
179
|
rows = result.fetchall()
|
|
@@ -199,9 +199,9 @@ class SearchRepository:
|
|
|
199
199
|
for row in rows
|
|
200
200
|
]
|
|
201
201
|
|
|
202
|
-
#for r in results:
|
|
202
|
+
# for r in results:
|
|
203
203
|
# logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
|
|
204
|
-
|
|
204
|
+
|
|
205
205
|
return results
|
|
206
206
|
|
|
207
207
|
async def index_item(
|
|
@@ -248,17 +248,14 @@ class SearchRepository:
|
|
|
248
248
|
async def execute_query(
|
|
249
249
|
self,
|
|
250
250
|
query: Executable,
|
|
251
|
-
params:
|
|
251
|
+
params: Dict[str, Any],
|
|
252
252
|
) -> Result[Any]:
|
|
253
253
|
"""Execute a query asynchronously."""
|
|
254
|
-
#logger.debug(f"Executing query: {query}")
|
|
254
|
+
# logger.debug(f"Executing query: {query}, params: {params}")
|
|
255
255
|
async with db.scoped_session(self.session_maker) as session:
|
|
256
256
|
start_time = time.perf_counter()
|
|
257
|
-
|
|
258
|
-
result = await session.execute(query, params)
|
|
259
|
-
else:
|
|
260
|
-
result = await session.execute(query)
|
|
257
|
+
result = await session.execute(query, params)
|
|
261
258
|
end_time = time.perf_counter()
|
|
262
259
|
elapsed_time = end_time - start_time
|
|
263
260
|
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
|
|
264
|
-
return result
|
|
261
|
+
return result
|
basic_memory/schemas/__init__.py
CHANGED
|
@@ -23,7 +23,7 @@ from basic_memory.schemas.delete import (
|
|
|
23
23
|
from basic_memory.schemas.request import (
|
|
24
24
|
SearchNodesRequest,
|
|
25
25
|
GetEntitiesRequest,
|
|
26
|
-
CreateRelationsRequest,
|
|
26
|
+
CreateRelationsRequest,
|
|
27
27
|
)
|
|
28
28
|
|
|
29
29
|
# Response models
|
|
@@ -40,7 +40,8 @@ from basic_memory.schemas.response import (
|
|
|
40
40
|
# Discovery and analytics models
|
|
41
41
|
from basic_memory.schemas.discovery import (
|
|
42
42
|
EntityTypeList,
|
|
43
|
-
ObservationCategoryList,
|
|
43
|
+
ObservationCategoryList,
|
|
44
|
+
TypedEntityList,
|
|
44
45
|
)
|
|
45
46
|
|
|
46
47
|
# For convenient imports, export all models
|
|
@@ -55,7 +56,6 @@ __all__ = [
|
|
|
55
56
|
"SearchNodesRequest",
|
|
56
57
|
"GetEntitiesRequest",
|
|
57
58
|
"CreateRelationsRequest",
|
|
58
|
-
"UpdateEntityRequest",
|
|
59
59
|
# Responses
|
|
60
60
|
"SQLAlchemyModel",
|
|
61
61
|
"ObservationResponse",
|
|
@@ -69,5 +69,5 @@ __all__ = [
|
|
|
69
69
|
# Discovery and Analytics
|
|
70
70
|
"EntityTypeList",
|
|
71
71
|
"ObservationCategoryList",
|
|
72
|
-
"TypedEntityList"
|
|
72
|
+
"TypedEntityList",
|
|
73
73
|
]
|
basic_memory/schemas/base.py
CHANGED
|
@@ -14,14 +14,13 @@ Key Concepts:
|
|
|
14
14
|
import mimetypes
|
|
15
15
|
import re
|
|
16
16
|
from datetime import datetime
|
|
17
|
-
from enum import Enum
|
|
18
17
|
from pathlib import Path
|
|
19
18
|
from typing import List, Optional, Annotated, Dict
|
|
20
19
|
|
|
21
20
|
from annotated_types import MinLen, MaxLen
|
|
22
21
|
from dateparser import parse
|
|
23
22
|
|
|
24
|
-
from pydantic import BaseModel, BeforeValidator, Field, model_validator
|
|
23
|
+
from pydantic import BaseModel, BeforeValidator, Field, model_validator
|
|
25
24
|
|
|
26
25
|
from basic_memory.utils import generate_permalink
|
|
27
26
|
|
|
@@ -47,44 +46,6 @@ def to_snake_case(name: str) -> str:
|
|
|
47
46
|
return s2.lower()
|
|
48
47
|
|
|
49
48
|
|
|
50
|
-
def validate_path_format(path: str) -> str:
|
|
51
|
-
"""Validate path has the correct format: not empty."""
|
|
52
|
-
if not path or not isinstance(path, str):
|
|
53
|
-
raise ValueError("Path must be a non-empty string")
|
|
54
|
-
|
|
55
|
-
return path
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class ObservationCategory(str, Enum):
|
|
59
|
-
"""Categories for structuring observations.
|
|
60
|
-
|
|
61
|
-
Categories help organize knowledge and make it easier to find later:
|
|
62
|
-
- tech: Implementation details and technical notes
|
|
63
|
-
- design: Architecture decisions and patterns
|
|
64
|
-
- feature: User-facing capabilities
|
|
65
|
-
- note: General observations (default)
|
|
66
|
-
- issue: Problems or concerns
|
|
67
|
-
- todo: Future work items
|
|
68
|
-
|
|
69
|
-
Categories are case-insensitive for easier use.
|
|
70
|
-
"""
|
|
71
|
-
|
|
72
|
-
TECH = "tech"
|
|
73
|
-
DESIGN = "design"
|
|
74
|
-
FEATURE = "feature"
|
|
75
|
-
NOTE = "note"
|
|
76
|
-
ISSUE = "issue"
|
|
77
|
-
TODO = "todo"
|
|
78
|
-
|
|
79
|
-
@classmethod
|
|
80
|
-
def _missing_(cls, value: str) -> "ObservationCategory":
|
|
81
|
-
"""Handle case-insensitive lookup."""
|
|
82
|
-
try:
|
|
83
|
-
return cls(value.lower())
|
|
84
|
-
except ValueError:
|
|
85
|
-
return None
|
|
86
|
-
|
|
87
|
-
|
|
88
49
|
def validate_timeframe(timeframe: str) -> str:
|
|
89
50
|
"""Convert human readable timeframes to a duration relative to the current time."""
|
|
90
51
|
if not isinstance(timeframe, str):
|
|
@@ -110,12 +71,9 @@ def validate_timeframe(timeframe: str) -> str:
|
|
|
110
71
|
return f"{days}d"
|
|
111
72
|
|
|
112
73
|
|
|
113
|
-
TimeFrame = Annotated[
|
|
114
|
-
str,
|
|
115
|
-
BeforeValidator(validate_timeframe)
|
|
116
|
-
]
|
|
74
|
+
TimeFrame = Annotated[str, BeforeValidator(validate_timeframe)]
|
|
117
75
|
|
|
118
|
-
|
|
76
|
+
Permalink = Annotated[str, MinLen(1)]
|
|
119
77
|
"""Unique identifier in format '{path}/{normalized_name}'."""
|
|
120
78
|
|
|
121
79
|
|
|
@@ -149,14 +107,16 @@ ObservationStr = Annotated[
|
|
|
149
107
|
MaxLen(1000), # Keep reasonable length
|
|
150
108
|
]
|
|
151
109
|
|
|
110
|
+
|
|
152
111
|
class Observation(BaseModel):
|
|
153
112
|
"""A single observation with category, content, and optional context."""
|
|
154
113
|
|
|
155
|
-
category:
|
|
114
|
+
category: Optional[str] = None
|
|
156
115
|
content: ObservationStr
|
|
157
116
|
tags: Optional[List[str]] = Field(default_factory=list)
|
|
158
117
|
context: Optional[str] = None
|
|
159
118
|
|
|
119
|
+
|
|
160
120
|
class Relation(BaseModel):
|
|
161
121
|
"""Represents a directed edge between entities in the knowledge graph.
|
|
162
122
|
|
|
@@ -165,8 +125,8 @@ class Relation(BaseModel):
|
|
|
165
125
|
or recipient entity.
|
|
166
126
|
"""
|
|
167
127
|
|
|
168
|
-
from_id:
|
|
169
|
-
to_id:
|
|
128
|
+
from_id: Permalink
|
|
129
|
+
to_id: Permalink
|
|
170
130
|
relation_type: RelationType
|
|
171
131
|
context: Optional[str] = None
|
|
172
132
|
|
|
@@ -181,7 +141,7 @@ class Entity(BaseModel):
|
|
|
181
141
|
- Optional relations to other entities
|
|
182
142
|
- Optional description for high-level overview
|
|
183
143
|
"""
|
|
184
|
-
|
|
144
|
+
|
|
185
145
|
# private field to override permalink
|
|
186
146
|
_permalink: Optional[str] = None
|
|
187
147
|
|
|
@@ -192,28 +152,27 @@ class Entity(BaseModel):
|
|
|
192
152
|
entity_metadata: Optional[Dict] = Field(default=None, description="Optional metadata")
|
|
193
153
|
content_type: ContentType = Field(
|
|
194
154
|
description="MIME type of the content (e.g. text/markdown, image/jpeg)",
|
|
195
|
-
examples=["text/markdown", "image/jpeg"],
|
|
155
|
+
examples=["text/markdown", "image/jpeg"],
|
|
156
|
+
default="text/markdown",
|
|
196
157
|
)
|
|
197
158
|
|
|
198
159
|
@property
|
|
199
160
|
def file_path(self):
|
|
200
161
|
"""Get the file path for this entity based on its permalink."""
|
|
201
162
|
return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
|
|
202
|
-
|
|
163
|
+
|
|
203
164
|
@property
|
|
204
|
-
def permalink(self) ->
|
|
165
|
+
def permalink(self) -> Permalink:
|
|
205
166
|
"""Get a url friendly path}."""
|
|
206
167
|
return self._permalink or generate_permalink(self.file_path)
|
|
207
168
|
|
|
208
169
|
@model_validator(mode="after")
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if not entity.content_type:
|
|
213
|
-
path = Path(entity.file_path)
|
|
170
|
+
def infer_content_type(self) -> "Entity": # pragma: no cover
|
|
171
|
+
if not self.content_type:
|
|
172
|
+
path = Path(self.file_path)
|
|
214
173
|
if not path.exists():
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return
|
|
174
|
+
self.content_type = "text/plain"
|
|
175
|
+
else:
|
|
176
|
+
mime_type, _ = mimetypes.guess_type(path.name)
|
|
177
|
+
self.content_type = mime_type or "text/plain"
|
|
178
|
+
return self
|
basic_memory/schemas/delete.py
CHANGED
|
@@ -21,7 +21,7 @@ from typing import List, Annotated
|
|
|
21
21
|
from annotated_types import MinLen
|
|
22
22
|
from pydantic import BaseModel
|
|
23
23
|
|
|
24
|
-
from basic_memory.schemas.base import
|
|
24
|
+
from basic_memory.schemas.base import Permalink
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class DeleteEntitiesRequest(BaseModel):
|
|
@@ -34,5 +34,4 @@ class DeleteEntitiesRequest(BaseModel):
|
|
|
34
34
|
4. Deletes the corresponding markdown file
|
|
35
35
|
"""
|
|
36
36
|
|
|
37
|
-
permalinks: Annotated[List[
|
|
38
|
-
|
|
37
|
+
permalinks: Annotated[List[Permalink], MinLen(1)]
|
|
@@ -8,18 +8,21 @@ from basic_memory.schemas.response import EntityResponse
|
|
|
8
8
|
|
|
9
9
|
class EntityTypeList(BaseModel):
|
|
10
10
|
"""List of unique entity types in the system."""
|
|
11
|
+
|
|
11
12
|
types: List[str]
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class ObservationCategoryList(BaseModel):
|
|
15
16
|
"""List of unique observation categories in the system."""
|
|
17
|
+
|
|
16
18
|
categories: List[str]
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
class TypedEntityList(BaseModel):
|
|
20
22
|
"""List of entities of a specific type."""
|
|
23
|
+
|
|
21
24
|
entity_type: str = Field(..., description="Type of entities in the list")
|
|
22
25
|
entities: List[EntityResponse]
|
|
23
26
|
total: int = Field(..., description="Total number of entities")
|
|
24
27
|
sort_by: Optional[str] = Field(None, description="Field used for sorting")
|
|
25
|
-
include_related: bool = Field(False, description="Whether related entities are included")
|
|
28
|
+
include_related: bool = Field(False, description="Whether related entities are included")
|