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.

Files changed (78) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/README +1 -0
  3. basic_memory/alembic/env.py +75 -0
  4. basic_memory/alembic/migrations.py +29 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  7. basic_memory/api/__init__.py +2 -1
  8. basic_memory/api/app.py +26 -24
  9. basic_memory/api/routers/knowledge_router.py +28 -26
  10. basic_memory/api/routers/memory_router.py +17 -11
  11. basic_memory/api/routers/search_router.py +6 -12
  12. basic_memory/cli/__init__.py +1 -1
  13. basic_memory/cli/app.py +0 -1
  14. basic_memory/cli/commands/__init__.py +3 -3
  15. basic_memory/cli/commands/db.py +25 -0
  16. basic_memory/cli/commands/import_memory_json.py +35 -31
  17. basic_memory/cli/commands/mcp.py +20 -0
  18. basic_memory/cli/commands/status.py +10 -6
  19. basic_memory/cli/commands/sync.py +5 -56
  20. basic_memory/cli/main.py +5 -39
  21. basic_memory/config.py +3 -3
  22. basic_memory/db.py +19 -21
  23. basic_memory/deps.py +3 -4
  24. basic_memory/file_utils.py +36 -35
  25. basic_memory/markdown/entity_parser.py +13 -30
  26. basic_memory/markdown/markdown_processor.py +7 -7
  27. basic_memory/markdown/plugins.py +109 -123
  28. basic_memory/markdown/schemas.py +7 -8
  29. basic_memory/markdown/utils.py +70 -121
  30. basic_memory/mcp/__init__.py +1 -1
  31. basic_memory/mcp/async_client.py +0 -2
  32. basic_memory/mcp/server.py +3 -27
  33. basic_memory/mcp/tools/__init__.py +5 -3
  34. basic_memory/mcp/tools/knowledge.py +2 -2
  35. basic_memory/mcp/tools/memory.py +8 -4
  36. basic_memory/mcp/tools/search.py +2 -1
  37. basic_memory/mcp/tools/utils.py +1 -1
  38. basic_memory/models/__init__.py +1 -2
  39. basic_memory/models/base.py +3 -3
  40. basic_memory/models/knowledge.py +23 -60
  41. basic_memory/models/search.py +1 -1
  42. basic_memory/repository/__init__.py +5 -3
  43. basic_memory/repository/entity_repository.py +34 -98
  44. basic_memory/repository/relation_repository.py +0 -7
  45. basic_memory/repository/repository.py +2 -39
  46. basic_memory/repository/search_repository.py +21 -24
  47. basic_memory/schemas/__init__.py +4 -4
  48. basic_memory/schemas/base.py +21 -62
  49. basic_memory/schemas/delete.py +2 -3
  50. basic_memory/schemas/discovery.py +4 -1
  51. basic_memory/schemas/memory.py +12 -13
  52. basic_memory/schemas/request.py +4 -23
  53. basic_memory/schemas/response.py +10 -9
  54. basic_memory/schemas/search.py +4 -7
  55. basic_memory/services/__init__.py +2 -7
  56. basic_memory/services/context_service.py +116 -110
  57. basic_memory/services/entity_service.py +25 -62
  58. basic_memory/services/exceptions.py +1 -0
  59. basic_memory/services/file_service.py +73 -109
  60. basic_memory/services/link_resolver.py +9 -9
  61. basic_memory/services/search_service.py +22 -15
  62. basic_memory/services/service.py +3 -24
  63. basic_memory/sync/__init__.py +2 -2
  64. basic_memory/sync/file_change_scanner.py +3 -7
  65. basic_memory/sync/sync_service.py +38 -38
  66. basic_memory/sync/utils.py +6 -38
  67. basic_memory/sync/watch_service.py +26 -5
  68. basic_memory/utils.py +42 -33
  69. {basic_memory-0.1.0.dist-info → basic_memory-0.1.2.dist-info}/METADATA +2 -7
  70. basic_memory-0.1.2.dist-info/RECORD +78 -0
  71. basic_memory/cli/commands/init.py +0 -38
  72. basic_memory/mcp/main.py +0 -21
  73. basic_memory/mcp/tools/ai_edit.py +0 -84
  74. basic_memory/services/database_service.py +0 -158
  75. basic_memory-0.1.0.dist-info/RECORD +0 -75
  76. {basic_memory-0.1.0.dist-info → basic_memory-0.1.2.dist-info}/WHEEL +0 -0
  77. {basic_memory-0.1.0.dist-info → basic_memory-0.1.2.dist-info}/entry_points.txt +0 -0
  78. {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 typing import List, Optional, Sequence
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
- async def get_by_file_path(self, file_path: str) -> Optional[Entity]:
32
- """Get entity by file_path."""
33
- query = self.select().where(Entity.file_path == file_path).options(*self.get_load_options())
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 list_entities(
37
- self,
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
- Searches across:
88
- - Entity names
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
- result = await self.execute_query(query)
107
- return list(result.scalars().all())
54
+ return await self.find_one(query)
108
55
 
109
- async def delete_entities_by_doc_id(self, doc_id: int) -> bool:
110
- """Delete all entities associated with a document."""
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
- async def delete_by_file_path(self, file_path: str) -> bool:
114
- """Delete entity with the provided file_path."""
115
- return await self.delete_by_fields(file_path=file_path)
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[int] = None # entity
39
- content: Optional[int] = None # entity, observation
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: Optional[Dict[str, Any]] = None,
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
- if params:
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
@@ -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, UpdateEntityRequest,
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, TypedEntityList,
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
  ]
@@ -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, ValidationError
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
- PathId = Annotated[str, BeforeValidator(validate_path_format)]
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: ObservationCategory
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: PathId
169
- to_id: PathId
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"], default="text/markdown"
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) -> PathId:
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
- @classmethod
210
- def infer_content_type(cls, entity: "Entity") -> Dict | None:
211
- """Infer content_type from file_path if not provided."""
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
- return None
216
- mime_type, _ = mimetypes.guess_type(path.name)
217
- entity.content_type = mime_type or "text/plain"
218
-
219
- return entity
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
@@ -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 Relation, Observation, PathId
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[PathId], MinLen(1)]
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")