basic-memory 0.7.0__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (89) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +23 -1
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  7. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +106 -0
  8. basic_memory/api/app.py +9 -10
  9. basic_memory/api/routers/__init__.py +2 -1
  10. basic_memory/api/routers/knowledge_router.py +31 -5
  11. basic_memory/api/routers/memory_router.py +18 -17
  12. basic_memory/api/routers/project_info_router.py +275 -0
  13. basic_memory/api/routers/resource_router.py +105 -4
  14. basic_memory/api/routers/search_router.py +22 -4
  15. basic_memory/cli/app.py +54 -5
  16. basic_memory/cli/commands/__init__.py +15 -2
  17. basic_memory/cli/commands/db.py +9 -13
  18. basic_memory/cli/commands/import_chatgpt.py +26 -30
  19. basic_memory/cli/commands/import_claude_conversations.py +27 -29
  20. basic_memory/cli/commands/import_claude_projects.py +29 -31
  21. basic_memory/cli/commands/import_memory_json.py +26 -28
  22. basic_memory/cli/commands/mcp.py +7 -1
  23. basic_memory/cli/commands/project.py +119 -0
  24. basic_memory/cli/commands/project_info.py +167 -0
  25. basic_memory/cli/commands/status.py +14 -28
  26. basic_memory/cli/commands/sync.py +63 -22
  27. basic_memory/cli/commands/tool.py +253 -0
  28. basic_memory/cli/main.py +39 -1
  29. basic_memory/config.py +166 -4
  30. basic_memory/db.py +19 -4
  31. basic_memory/deps.py +10 -3
  32. basic_memory/file_utils.py +37 -19
  33. basic_memory/markdown/entity_parser.py +3 -3
  34. basic_memory/markdown/utils.py +5 -0
  35. basic_memory/mcp/async_client.py +1 -1
  36. basic_memory/mcp/main.py +24 -0
  37. basic_memory/mcp/prompts/__init__.py +19 -0
  38. basic_memory/mcp/prompts/ai_assistant_guide.py +26 -0
  39. basic_memory/mcp/prompts/continue_conversation.py +111 -0
  40. basic_memory/mcp/prompts/recent_activity.py +88 -0
  41. basic_memory/mcp/prompts/search.py +182 -0
  42. basic_memory/mcp/prompts/utils.py +155 -0
  43. basic_memory/mcp/server.py +2 -6
  44. basic_memory/mcp/tools/__init__.py +12 -21
  45. basic_memory/mcp/tools/build_context.py +85 -0
  46. basic_memory/mcp/tools/canvas.py +97 -0
  47. basic_memory/mcp/tools/delete_note.py +28 -0
  48. basic_memory/mcp/tools/project_info.py +51 -0
  49. basic_memory/mcp/tools/read_content.py +229 -0
  50. basic_memory/mcp/tools/read_note.py +190 -0
  51. basic_memory/mcp/tools/recent_activity.py +100 -0
  52. basic_memory/mcp/tools/search.py +56 -17
  53. basic_memory/mcp/tools/utils.py +245 -16
  54. basic_memory/mcp/tools/write_note.py +124 -0
  55. basic_memory/models/knowledge.py +27 -11
  56. basic_memory/models/search.py +2 -1
  57. basic_memory/repository/entity_repository.py +3 -2
  58. basic_memory/repository/project_info_repository.py +9 -0
  59. basic_memory/repository/repository.py +24 -7
  60. basic_memory/repository/search_repository.py +47 -14
  61. basic_memory/schemas/__init__.py +10 -9
  62. basic_memory/schemas/base.py +4 -1
  63. basic_memory/schemas/memory.py +14 -4
  64. basic_memory/schemas/project_info.py +96 -0
  65. basic_memory/schemas/search.py +29 -33
  66. basic_memory/services/context_service.py +3 -3
  67. basic_memory/services/entity_service.py +26 -13
  68. basic_memory/services/file_service.py +145 -26
  69. basic_memory/services/link_resolver.py +9 -46
  70. basic_memory/services/search_service.py +95 -22
  71. basic_memory/sync/__init__.py +3 -2
  72. basic_memory/sync/sync_service.py +523 -117
  73. basic_memory/sync/watch_service.py +258 -132
  74. basic_memory/utils.py +51 -36
  75. basic_memory-0.9.0.dist-info/METADATA +736 -0
  76. basic_memory-0.9.0.dist-info/RECORD +99 -0
  77. basic_memory/alembic/README +0 -1
  78. basic_memory/cli/commands/tools.py +0 -157
  79. basic_memory/mcp/tools/knowledge.py +0 -68
  80. basic_memory/mcp/tools/memory.py +0 -170
  81. basic_memory/mcp/tools/notes.py +0 -202
  82. basic_memory/schemas/discovery.py +0 -28
  83. basic_memory/sync/file_change_scanner.py +0 -158
  84. basic_memory/sync/utils.py +0 -31
  85. basic_memory-0.7.0.dist-info/METADATA +0 -378
  86. basic_memory-0.7.0.dist-info/RECORD +0 -82
  87. {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/WHEEL +0 -0
  88. {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/entry_points.txt +0 -0
  89. {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -21,31 +21,38 @@ class SearchIndexRow:
21
21
 
22
22
  id: int
23
23
  type: str
24
- permalink: str
25
24
  file_path: str
26
- metadata: Optional[dict] = None
27
25
 
28
26
  # date values
29
- created_at: Optional[datetime] = None
30
- updated_at: Optional[datetime] = None
27
+ created_at: datetime
28
+ updated_at: datetime
29
+
30
+ permalink: Optional[str] = None
31
+ metadata: Optional[dict] = None
31
32
 
32
33
  # assigned in result
33
34
  score: Optional[float] = None
34
35
 
35
36
  # Type-specific fields
36
37
  title: Optional[str] = None # entity
37
- content: Optional[str] = None # entity, observation
38
+ content_stems: Optional[str] = None # entity, observation
39
+ content_snippet: Optional[str] = None # entity, observation
38
40
  entity_id: Optional[int] = None # observations
39
41
  category: Optional[str] = None # observations
40
42
  from_id: Optional[int] = None # relations
41
43
  to_id: Optional[int] = None # relations
42
44
  relation_type: Optional[str] = None # relations
43
45
 
46
+ @property
47
+ def content(self):
48
+ return self.content_snippet
49
+
44
50
  def to_insert(self):
45
51
  return {
46
52
  "id": self.id,
47
53
  "title": self.title,
48
- "content": self.content,
54
+ "content_stems": self.content_stems,
55
+ "content_snippet": self.content_snippet,
49
56
  "permalink": self.permalink,
50
57
  "file_path": self.file_path,
51
58
  "type": self.type,
@@ -87,10 +94,16 @@ class SearchRepository:
87
94
  For FTS5:
88
95
  - Special characters and phrases need to be quoted
89
96
  - Terms with spaces or special chars need quotes
97
+ - Boolean operators (AND, OR, NOT) and parentheses are preserved
90
98
  """
91
99
  if "*" in term:
92
100
  return term
93
101
 
102
+ # Check for boolean operators - if present, return the term as is
103
+ boolean_operators = [" AND ", " OR ", " NOT ", "(", ")"]
104
+ if any(op in f" {term} " for op in boolean_operators):
105
+ return term
106
+
94
107
  # List of special characters that need quoting (excluding *)
95
108
  special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
96
109
 
@@ -123,9 +136,20 @@ class SearchRepository:
123
136
 
124
137
  # Handle text search for title and content
125
138
  if search_text:
126
- search_text = self._prepare_search_term(search_text.strip())
127
- params["text"] = search_text
128
- conditions.append("(title MATCH :text OR content MATCH :text)")
139
+ has_boolean = any(
140
+ op in f" {search_text} " for op in [" AND ", " OR ", " NOT ", "(", ")"]
141
+ )
142
+
143
+ if has_boolean:
144
+ # If boolean operators are present, use the raw query
145
+ # No need to prepare it, FTS5 will understand the operators
146
+ params["text"] = search_text
147
+ conditions.append("(title MATCH :text OR content_stems MATCH :text)")
148
+ else:
149
+ # Standard search with term preparation
150
+ processed_text = self._prepare_search_term(search_text.strip())
151
+ params["text"] = processed_text
152
+ conditions.append("(title MATCH :text OR content_stems MATCH :text)")
129
153
 
130
154
  # Handle title match search
131
155
  if title:
@@ -187,7 +211,7 @@ class SearchRepository:
187
211
  to_id,
188
212
  relation_type,
189
213
  entity_id,
190
- content,
214
+ content_snippet,
191
215
  category,
192
216
  created_at,
193
217
  updated_at,
@@ -199,7 +223,7 @@ class SearchRepository:
199
223
  OFFSET :offset
200
224
  """
201
225
 
202
- logger.debug(f"Search {sql} params: {params}")
226
+ logger.trace(f"Search {sql} params: {params}")
203
227
  async with db.scoped_session(self.session_maker) as session:
204
228
  result = await session.execute(text(sql), params)
205
229
  rows = result.fetchall()
@@ -217,7 +241,7 @@ class SearchRepository:
217
241
  to_id=row.to_id,
218
242
  relation_type=row.relation_type,
219
243
  entity_id=row.entity_id,
220
- content=row.content,
244
+ content_snippet=row.content_snippet,
221
245
  category=row.category,
222
246
  created_at=row.created_at,
223
247
  updated_at=row.updated_at,
@@ -249,12 +273,12 @@ class SearchRepository:
249
273
  await session.execute(
250
274
  text("""
251
275
  INSERT INTO search_index (
252
- id, title, content, permalink, file_path, type, metadata,
276
+ id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
253
277
  from_id, to_id, relation_type,
254
278
  entity_id, category,
255
279
  created_at, updated_at
256
280
  ) VALUES (
257
- :id, :title, :content, :permalink, :file_path, :type, :metadata,
281
+ :id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
258
282
  :from_id, :to_id, :relation_type,
259
283
  :entity_id, :category,
260
284
  :created_at, :updated_at
@@ -265,6 +289,15 @@ class SearchRepository:
265
289
  logger.debug(f"indexed row {search_index_row}")
266
290
  await session.commit()
267
291
 
292
+ async def delete_by_entity_id(self, entity_id: int):
293
+ """Delete an item from the search index by entity_id."""
294
+ async with db.scoped_session(self.session_maker) as session:
295
+ await session.execute(
296
+ text("DELETE FROM search_index WHERE entity_id = :entity_id"),
297
+ {"entity_id": entity_id},
298
+ )
299
+ await session.commit()
300
+
268
301
  async def delete_by_permalink(self, permalink: str):
269
302
  """Delete an item from the search index."""
270
303
  async with db.scoped_session(self.session_maker) as session:
@@ -37,11 +37,11 @@ from basic_memory.schemas.response import (
37
37
  DeleteEntitiesResponse,
38
38
  )
39
39
 
40
- # Discovery and analytics models
41
- from basic_memory.schemas.discovery import (
42
- EntityTypeList,
43
- ObservationCategoryList,
44
- TypedEntityList,
40
+ from basic_memory.schemas.project_info import (
41
+ ProjectStatistics,
42
+ ActivityMetrics,
43
+ SystemStatus,
44
+ ProjectInfoResponse,
45
45
  )
46
46
 
47
47
  # For convenient imports, export all models
@@ -66,8 +66,9 @@ __all__ = [
66
66
  "DeleteEntitiesResponse",
67
67
  # Delete Operations
68
68
  "DeleteEntitiesRequest",
69
- # Discovery and Analytics
70
- "EntityTypeList",
71
- "ObservationCategoryList",
72
- "TypedEntityList",
69
+ # Project Info
70
+ "ProjectStatistics",
71
+ "ActivityMetrics",
72
+ "SystemStatus",
73
+ "ProjectInfoResponse",
73
74
  ]
@@ -159,7 +159,10 @@ class Entity(BaseModel):
159
159
  @property
160
160
  def file_path(self):
161
161
  """Get the file path for this entity based on its permalink."""
162
- return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
162
+ if self.content_type == "text/markdown":
163
+ return f"{self.folder}/{self.title}.md" if self.folder else f"{self.title}.md"
164
+ else:
165
+ return f"{self.folder}/{self.title}" if self.folder else self.title
163
166
 
164
167
  @property
165
168
  def permalink(self) -> Permalink:
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, BeforeValidator, TypeAdapter
9
9
  from basic_memory.schemas.search import SearchItemType
10
10
 
11
11
 
12
- def normalize_memory_url(url: str) -> str:
12
+ def normalize_memory_url(url: str | None) -> str:
13
13
  """Normalize a MemoryUrl string.
14
14
 
15
15
  Args:
@@ -24,6 +24,9 @@ def normalize_memory_url(url: str) -> str:
24
24
  >>> normalize_memory_url("memory://specs/search")
25
25
  'memory://specs/search'
26
26
  """
27
+ if not url:
28
+ return ""
29
+
27
30
  clean_path = url.removeprefix("memory://")
28
31
  return f"memory://{clean_path}"
29
32
 
@@ -59,8 +62,9 @@ class EntitySummary(BaseModel):
59
62
  """Simplified entity representation."""
60
63
 
61
64
  type: str = "entity"
62
- permalink: str
65
+ permalink: Optional[str]
63
66
  title: str
67
+ content: Optional[str] = None
64
68
  file_path: str
65
69
  created_at: datetime
66
70
 
@@ -69,19 +73,25 @@ class RelationSummary(BaseModel):
69
73
  """Simplified relation representation."""
70
74
 
71
75
  type: str = "relation"
76
+ title: str
77
+ file_path: str
72
78
  permalink: str
73
79
  relation_type: str
74
- from_id: str
75
- to_id: Optional[str] = None
80
+ from_entity: str
81
+ to_entity: Optional[str] = None
82
+ created_at: datetime
76
83
 
77
84
 
78
85
  class ObservationSummary(BaseModel):
79
86
  """Simplified observation representation."""
80
87
 
81
88
  type: str = "observation"
89
+ title: str
90
+ file_path: str
82
91
  permalink: str
83
92
  category: str
84
93
  content: str
94
+ created_at: datetime
85
95
 
86
96
 
87
97
  class MemoryMetadata(BaseModel):
@@ -0,0 +1,96 @@
1
+ """Schema for project info response."""
2
+
3
+ from datetime import datetime
4
+ from typing import Dict, List, Optional, Any
5
+
6
+ from pydantic import Field, BaseModel
7
+
8
+
9
+ class ProjectStatistics(BaseModel):
10
+ """Statistics about the current project."""
11
+
12
+ # Basic counts
13
+ total_entities: int = Field(description="Total number of entities in the knowledge base")
14
+ total_observations: int = Field(description="Total number of observations across all entities")
15
+ total_relations: int = Field(description="Total number of relations between entities")
16
+ total_unresolved_relations: int = Field(
17
+ description="Number of relations with unresolved targets"
18
+ )
19
+
20
+ # Entity counts by type
21
+ entity_types: Dict[str, int] = Field(
22
+ description="Count of entities by type (e.g., note, conversation)"
23
+ )
24
+
25
+ # Observation counts by category
26
+ observation_categories: Dict[str, int] = Field(
27
+ description="Count of observations by category (e.g., tech, decision)"
28
+ )
29
+
30
+ # Relation counts by type
31
+ relation_types: Dict[str, int] = Field(
32
+ description="Count of relations by type (e.g., implements, relates_to)"
33
+ )
34
+
35
+ # Graph metrics
36
+ most_connected_entities: List[Dict[str, Any]] = Field(
37
+ description="Entities with the most relations, including their titles and permalinks"
38
+ )
39
+ isolated_entities: int = Field(description="Number of entities with no relations")
40
+
41
+
42
+ class ActivityMetrics(BaseModel):
43
+ """Activity metrics for the current project."""
44
+
45
+ # Recent activity
46
+ recently_created: List[Dict[str, Any]] = Field(
47
+ description="Recently created entities with timestamps"
48
+ )
49
+ recently_updated: List[Dict[str, Any]] = Field(
50
+ description="Recently updated entities with timestamps"
51
+ )
52
+
53
+ # Growth over time (last 6 months)
54
+ monthly_growth: Dict[str, Dict[str, int]] = Field(
55
+ description="Monthly growth statistics for entities, observations, and relations"
56
+ )
57
+
58
+
59
+ class SystemStatus(BaseModel):
60
+ """System status information."""
61
+
62
+ # Version information
63
+ version: str = Field(description="Basic Memory version")
64
+
65
+ # Database status
66
+ database_path: str = Field(description="Path to the SQLite database")
67
+ database_size: str = Field(description="Size of the database in human-readable format")
68
+
69
+ # Watch service status
70
+ watch_status: Optional[Dict[str, Any]] = Field(
71
+ default=None, description="Watch service status information (if running)"
72
+ )
73
+
74
+ # System information
75
+ timestamp: datetime = Field(description="Timestamp when the information was collected")
76
+
77
+
78
+ class ProjectInfoResponse(BaseModel):
79
+ """Response for the project_info tool."""
80
+
81
+ # Project configuration
82
+ project_name: str = Field(description="Name of the current project")
83
+ project_path: str = Field(description="Path to the current project files")
84
+ available_projects: Dict[str, str] = Field(
85
+ description="Map of configured project names to paths"
86
+ )
87
+ default_project: str = Field(description="Name of the default project")
88
+
89
+ # Statistics
90
+ statistics: ProjectStatistics = Field(description="Statistics about the knowledge base")
91
+
92
+ # Activity metrics
93
+ activity: ActivityMetrics = Field(description="Activity and growth metrics")
94
+
95
+ # System status
96
+ system: SystemStatus = Field(description="System and service status information")
@@ -11,6 +11,8 @@ from datetime import datetime
11
11
  from enum import Enum
12
12
  from pydantic import BaseModel, field_validator
13
13
 
14
+ from basic_memory.schemas.base import Permalink
15
+
14
16
 
15
17
  class SearchItemType(str, Enum):
16
18
  """Types of searchable items."""
@@ -26,18 +28,24 @@ class SearchQuery(BaseModel):
26
28
  Use ONE of these primary search modes:
27
29
  - permalink: Exact permalink match
28
30
  - permalink_match: Path pattern with *
29
- - text: Full-text search of title/content
31
+ - text: Full-text search of title/content (supports boolean operators: AND, OR, NOT)
30
32
 
31
33
  Optionally filter results by:
32
34
  - types: Limit to specific item types
33
35
  - entity_types: Limit to specific entity types
34
36
  - after_date: Only items after date
37
+
38
+ Boolean search examples:
39
+ - "python AND flask" - Find items with both terms
40
+ - "python OR django" - Find items with either term
41
+ - "python NOT django" - Find items with python but not django
42
+ - "(python OR flask) AND web" - Use parentheses for grouping
35
43
  """
36
44
 
37
45
  # Primary search modes (use ONE of these)
38
46
  permalink: Optional[str] = None # Exact permalink match
39
- permalink_match: Optional[str] = None # Exact permalink match
40
- text: Optional[str] = None # Full-text search
47
+ permalink_match: Optional[str] = None # Glob permalink match
48
+ text: Optional[str] = None # Full-text search (now supports boolean operators)
41
49
  title: Optional[str] = None # title only search
42
50
 
43
51
  # Optional filters
@@ -57,60 +65,48 @@ class SearchQuery(BaseModel):
57
65
  return (
58
66
  self.permalink is None
59
67
  and self.permalink_match is None
68
+ and self.title is None
60
69
  and self.text is None
61
70
  and self.after_date is None
62
71
  and self.types is None
63
72
  and self.entity_types is None
64
73
  )
65
74
 
75
+ def has_boolean_operators(self) -> bool:
76
+ """Check if the text query contains boolean operators (AND, OR, NOT)."""
77
+ if not self.text: # pragma: no cover
78
+ return False
79
+
80
+ # Check for common boolean operators with correct word boundaries
81
+ # to avoid matching substrings like "GRAND" containing "AND"
82
+ boolean_patterns = [" AND ", " OR ", " NOT ", "(", ")"]
83
+ text = f" {self.text} " # Add spaces to ensure we match word boundaries
84
+ return any(pattern in text for pattern in boolean_patterns)
85
+
66
86
 
67
87
  class SearchResult(BaseModel):
68
88
  """Search result with score and metadata."""
69
89
 
70
- id: int
90
+ title: str
71
91
  type: SearchItemType
72
92
  score: float
73
- permalink: str
93
+ entity: Optional[Permalink]
94
+ permalink: Optional[str]
95
+ content: Optional[str] = None
74
96
  file_path: str
75
97
 
76
98
  metadata: Optional[dict] = None
77
99
 
78
100
  # Type-specific fields
79
- entity_id: Optional[int] = None # For observations
80
101
  category: Optional[str] = None # For observations
81
- from_id: Optional[int] = None # For relations
82
- to_id: Optional[int] = None # For relations
102
+ from_entity: Optional[Permalink] = None # For relations
103
+ to_entity: Optional[Permalink] = None # For relations
83
104
  relation_type: Optional[str] = None # For relations
84
105
 
85
106
 
86
- class RelatedResult(BaseModel):
87
- type: SearchItemType
88
- id: int
89
- title: str
90
- permalink: str
91
- depth: int
92
- root_id: int
93
- created_at: datetime
94
- from_id: Optional[int] = None
95
- to_id: Optional[int] = None
96
- relation_type: Optional[str] = None
97
- category: Optional[str] = None
98
- entity_id: Optional[int] = None
99
-
100
-
101
107
  class SearchResponse(BaseModel):
102
108
  """Wrapper for search results."""
103
109
 
104
110
  results: List[SearchResult]
105
111
  current_page: int
106
112
  page_size: int
107
-
108
-
109
- # Schema for future advanced search endpoint
110
- class AdvancedSearchQuery(BaseModel):
111
- """Advanced full-text search with explicit FTS5 syntax."""
112
-
113
- query: str # Raw FTS5 query (e.g., "foo AND bar")
114
- types: Optional[List[SearchItemType]] = None
115
- entity_types: Optional[List[str]] = None
116
- after_date: Optional[Union[datetime, str]] = None
@@ -165,7 +165,7 @@ class ContextService:
165
165
  from_id,
166
166
  to_id,
167
167
  relation_type,
168
- content,
168
+ content_snippet as content,
169
169
  category,
170
170
  entity_id,
171
171
  0 as depth,
@@ -189,7 +189,7 @@ class ContextService:
189
189
  r.from_id,
190
190
  r.to_id,
191
191
  r.relation_type,
192
- r.content,
192
+ r.content_snippet as content,
193
193
  r.category,
194
194
  r.entity_id,
195
195
  cg.depth + 1,
@@ -218,7 +218,7 @@ class ContextService:
218
218
  e.from_id,
219
219
  e.to_id,
220
220
  e.relation_type,
221
- e.content,
221
+ e.content_snippet as content,
222
222
  e.category,
223
223
  e.entity_id,
224
224
  cg.depth + 1, -- Increment depth for entities
@@ -124,17 +124,19 @@ class EntityService(BaseService[EntityModel]):
124
124
  entity_markdown = await self.entity_parser.parse_file(file_path)
125
125
 
126
126
  # create entity
127
- await self.create_entity_from_markdown(file_path, entity_markdown)
127
+ created = await self.create_entity_from_markdown(file_path, entity_markdown)
128
128
 
129
129
  # add relations
130
- entity = await self.update_entity_relations(file_path, entity_markdown)
130
+ entity = await self.update_entity_relations(created.file_path, entity_markdown)
131
131
 
132
132
  # Set final checksum to mark complete
133
133
  return await self.repository.update(entity.id, {"checksum": checksum})
134
134
 
135
135
  async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> EntityModel:
136
136
  """Update an entity's content and metadata."""
137
- logger.debug(f"Updating entity with permalink: {entity.permalink}")
137
+ logger.debug(
138
+ f"Updating entity with permalink: {entity.permalink} content-type: {schema.content_type}"
139
+ )
138
140
 
139
141
  # Convert file path string to Path
140
142
  file_path = Path(entity.file_path)
@@ -142,7 +144,7 @@ class EntityService(BaseService[EntityModel]):
142
144
  post = await schema_to_markdown(schema)
143
145
 
144
146
  # write file
145
- final_content = frontmatter.dumps(post)
147
+ final_content = frontmatter.dumps(post, sort_keys=False)
146
148
  checksum = await self.file_service.write_file(file_path, final_content)
147
149
 
148
150
  # parse entity from file
@@ -152,20 +154,31 @@ class EntityService(BaseService[EntityModel]):
152
154
  entity = await self.update_entity_and_observations(file_path, entity_markdown)
153
155
 
154
156
  # add relations
155
- await self.update_entity_relations(file_path, entity_markdown)
157
+ await self.update_entity_relations(str(file_path), entity_markdown)
156
158
 
157
159
  # Set final checksum to match file
158
160
  entity = await self.repository.update(entity.id, {"checksum": checksum})
159
161
 
160
162
  return entity
161
163
 
162
- async def delete_entity(self, permalink: str) -> bool:
164
+ async def delete_entity(self, permalink_or_id: str | int) -> bool:
163
165
  """Delete entity and its file."""
164
- logger.debug(f"Deleting entity: {permalink}")
166
+ logger.debug(f"Deleting entity: {permalink_or_id}")
165
167
 
166
168
  try:
167
169
  # Get entity first for file deletion
168
- entity = await self.get_by_permalink(permalink)
170
+ if isinstance(permalink_or_id, str):
171
+ entity = await self.get_by_permalink(permalink_or_id)
172
+ else:
173
+ entities = await self.get_entities_by_id([permalink_or_id])
174
+ if len(entities) != 1: # pragma: no cover
175
+ logger.error(
176
+ "Entity lookup error", entity_id=permalink_or_id, found_count=len(entities)
177
+ )
178
+ raise ValueError(
179
+ f"Expected 1 entity with ID {permalink_or_id}, got {len(entities)}"
180
+ )
181
+ entity = entities[0]
169
182
 
170
183
  # Delete file first
171
184
  await self.file_service.delete_entity_file(entity)
@@ -174,7 +187,7 @@ class EntityService(BaseService[EntityModel]):
174
187
  return await self.repository.delete(entity.id)
175
188
 
176
189
  except EntityNotFoundError:
177
- logger.info(f"Entity not found: {permalink}")
190
+ logger.info(f"Entity not found: {permalink_or_id}")
178
191
  return True # Already deleted
179
192
 
180
193
  async def get_by_permalink(self, permalink: str) -> EntityModel:
@@ -256,13 +269,13 @@ class EntityService(BaseService[EntityModel]):
256
269
 
257
270
  async def update_entity_relations(
258
271
  self,
259
- file_path: Path,
272
+ path: str,
260
273
  markdown: EntityMarkdown,
261
274
  ) -> EntityModel:
262
275
  """Update relations for entity"""
263
- logger.debug(f"Updating relations for entity: {file_path}")
276
+ logger.debug(f"Updating relations for entity: {path}")
264
277
 
265
- db_entity = await self.repository.get_by_file_path(str(file_path))
278
+ db_entity = await self.repository.get_by_file_path(path)
266
279
 
267
280
  # Clear existing relations first
268
281
  await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
@@ -296,4 +309,4 @@ class EntityService(BaseService[EntityModel]):
296
309
  )
297
310
  continue
298
311
 
299
- return await self.repository.get_by_file_path(str(file_path))
312
+ return await self.repository.get_by_file_path(path)