basic-memory 0.1.1__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 -38
- basic_memory/config.py +3 -3
- basic_memory/db.py +15 -22
- 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 +20 -25
- 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 +35 -40
- 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.1.dist-info → basic_memory-0.1.2.dist-info}/METADATA +2 -7
- basic_memory-0.1.2.dist-info/RECORD +78 -0
- basic_memory/mcp/main.py +0 -21
- basic_memory/mcp/tools/ai_edit.py +0 -84
- basic_memory/services/database_service.py +0 -159
- basic_memory-0.1.1.dist-info/RECORD +0 -74
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/WHEEL +0 -0
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.1.1.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,7 +68,7 @@ class SearchRepository:
|
|
|
70
68
|
|
|
71
69
|
async def init_search_index(self):
|
|
72
70
|
"""Create or recreate the search index."""
|
|
73
|
-
|
|
71
|
+
|
|
74
72
|
logger.info("Initializing search index")
|
|
75
73
|
async with db.scoped_session(self.session_maker) as session:
|
|
76
74
|
await session.execute(CREATE_SEARCH_INDEX)
|
|
@@ -81,8 +79,8 @@ class SearchRepository:
|
|
|
81
79
|
For FTS5, special characters and phrases need to be quoted to be treated as a single token.
|
|
82
80
|
"""
|
|
83
81
|
# List of special characters that need quoting
|
|
84
|
-
special_chars = [
|
|
85
|
-
|
|
82
|
+
special_chars = ["/", "*", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
|
|
83
|
+
|
|
86
84
|
# Check if term contains any special characters
|
|
87
85
|
if any(c in term for c in special_chars):
|
|
88
86
|
# If the term already contains quotes, escape them
|
|
@@ -96,16 +94,16 @@ class SearchRepository:
|
|
|
96
94
|
permalink: Optional[str] = None,
|
|
97
95
|
permalink_match: Optional[str] = None,
|
|
98
96
|
title: Optional[str] = None,
|
|
99
|
-
types: List[SearchItemType] = None,
|
|
97
|
+
types: Optional[List[SearchItemType]] = None,
|
|
100
98
|
after_date: Optional[datetime] = None,
|
|
101
|
-
entity_types: List[str] = None,
|
|
99
|
+
entity_types: Optional[List[str]] = None,
|
|
102
100
|
limit: int = 10,
|
|
103
101
|
) -> List[SearchIndexRow]:
|
|
104
102
|
"""Search across all indexed content with fuzzy matching."""
|
|
105
103
|
conditions = []
|
|
106
104
|
params = {}
|
|
107
105
|
order_by_clause = ""
|
|
108
|
-
|
|
106
|
+
|
|
109
107
|
# Handle text search for title and content
|
|
110
108
|
if search_text:
|
|
111
109
|
search_text = self._quote_search_term(search_text.lower().strip())
|
|
@@ -127,7 +125,7 @@ class SearchRepository:
|
|
|
127
125
|
if permalink_match:
|
|
128
126
|
params["permalink"] = self._quote_search_term(permalink_match)
|
|
129
127
|
conditions.append("permalink MATCH :permalink")
|
|
130
|
-
|
|
128
|
+
|
|
131
129
|
# Handle type filter
|
|
132
130
|
if types:
|
|
133
131
|
type_list = ", ".join(f"'{t.value}'" for t in types)
|
|
@@ -142,13 +140,13 @@ class SearchRepository:
|
|
|
142
140
|
if after_date:
|
|
143
141
|
params["after_date"] = after_date
|
|
144
142
|
conditions.append("datetime(created_at) > datetime(:after_date)")
|
|
145
|
-
|
|
143
|
+
|
|
146
144
|
# order by most recent first
|
|
147
145
|
order_by_clause = ", updated_at DESC"
|
|
148
146
|
|
|
149
147
|
# set limit on search query
|
|
150
148
|
params["limit"] = limit
|
|
151
|
-
|
|
149
|
+
|
|
152
150
|
# Build WHERE clause
|
|
153
151
|
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
|
154
152
|
|
|
@@ -175,7 +173,7 @@ class SearchRepository:
|
|
|
175
173
|
LIMIT :limit
|
|
176
174
|
"""
|
|
177
175
|
|
|
178
|
-
#logger.debug(f"Search {sql} params: {params}")
|
|
176
|
+
# logger.debug(f"Search {sql} params: {params}")
|
|
179
177
|
async with db.scoped_session(self.session_maker) as session:
|
|
180
178
|
result = await session.execute(text(sql), params)
|
|
181
179
|
rows = result.fetchall()
|
|
@@ -201,9 +199,9 @@ class SearchRepository:
|
|
|
201
199
|
for row in rows
|
|
202
200
|
]
|
|
203
201
|
|
|
204
|
-
#for r in results:
|
|
202
|
+
# for r in results:
|
|
205
203
|
# logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
|
|
206
|
-
|
|
204
|
+
|
|
207
205
|
return results
|
|
208
206
|
|
|
209
207
|
async def index_item(
|
|
@@ -250,17 +248,14 @@ class SearchRepository:
|
|
|
250
248
|
async def execute_query(
|
|
251
249
|
self,
|
|
252
250
|
query: Executable,
|
|
253
|
-
params:
|
|
251
|
+
params: Dict[str, Any],
|
|
254
252
|
) -> Result[Any]:
|
|
255
253
|
"""Execute a query asynchronously."""
|
|
256
|
-
#logger.debug(f"Executing query: {query}")
|
|
254
|
+
# logger.debug(f"Executing query: {query}, params: {params}")
|
|
257
255
|
async with db.scoped_session(self.session_maker) as session:
|
|
258
256
|
start_time = time.perf_counter()
|
|
259
|
-
|
|
260
|
-
result = await session.execute(query, params)
|
|
261
|
-
else:
|
|
262
|
-
result = await session.execute(query)
|
|
257
|
+
result = await session.execute(query, params)
|
|
263
258
|
end_time = time.perf_counter()
|
|
264
259
|
elapsed_time = end_time - start_time
|
|
265
260
|
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
|
|
266
|
-
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")
|