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.

Files changed (77) 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 -38
  21. basic_memory/config.py +3 -3
  22. basic_memory/db.py +15 -22
  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 +20 -25
  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 +35 -40
  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.1.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/mcp/main.py +0 -21
  72. basic_memory/mcp/tools/ai_edit.py +0 -84
  73. basic_memory/services/database_service.py +0 -159
  74. basic_memory-0.1.1.dist-info/RECORD +0 -74
  75. {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/WHEEL +0 -0
  76. {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/entry_points.txt +0 -0
  77. {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,10 +1,9 @@
1
1
  """Service for managing entities in the database."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import Sequence, List, Optional
4
+ from typing import Sequence, List, Optional, Tuple, Union
5
5
 
6
6
  import frontmatter
7
- from frontmatter import Post
8
7
  from loguru import logger
9
8
  from sqlalchemy.exc import IntegrityError
10
9
 
@@ -14,6 +13,7 @@ from basic_memory.models import Entity as EntityModel, Observation, Relation
14
13
  from basic_memory.repository import ObservationRepository, RelationRepository
15
14
  from basic_memory.repository.entity_repository import EntityRepository
16
15
  from basic_memory.schemas import Entity as EntitySchema
16
+ from basic_memory.schemas.base import Permalink
17
17
  from basic_memory.services.exceptions import EntityNotFoundError, EntityCreationError
18
18
  from basic_memory.services import FileService
19
19
  from basic_memory.services import BaseService
@@ -42,9 +42,7 @@ class EntityService(BaseService[EntityModel]):
42
42
  self.link_resolver = link_resolver
43
43
 
44
44
  async def resolve_permalink(
45
- self,
46
- file_path: Path,
47
- markdown: Optional[EntityMarkdown] = None
45
+ self, file_path: Permalink | Path, markdown: Optional[EntityMarkdown] = None
48
46
  ) -> str:
49
47
  """Get or generate unique permalink for an entity.
50
48
 
@@ -54,19 +52,17 @@ class EntityService(BaseService[EntityModel]):
54
52
  3. For existing files, keep current permalink from db
55
53
  4. Generate new unique permalink from file path
56
54
  """
57
- file_path = str(file_path)
58
-
59
55
  # If markdown has explicit permalink, try to validate it
60
56
  if markdown and markdown.frontmatter.permalink:
61
57
  desired_permalink = markdown.frontmatter.permalink
62
58
  existing = await self.repository.get_by_permalink(desired_permalink)
63
59
 
64
60
  # If no conflict or it's our own file, use as is
65
- if not existing or existing.file_path == file_path:
61
+ if not existing or existing.file_path == str(file_path):
66
62
  return desired_permalink
67
63
 
68
64
  # For existing files, try to find current permalink
69
- existing = await self.repository.get_by_file_path(file_path)
65
+ existing = await self.repository.get_by_file_path(str(file_path))
70
66
  if existing:
71
67
  return existing.permalink
72
68
 
@@ -85,12 +81,11 @@ class EntityService(BaseService[EntityModel]):
85
81
  logger.debug(f"creating unique permalink: {permalink}")
86
82
 
87
83
  return permalink
88
-
89
- async def create_or_update_entity(self, schema: EntitySchema) -> (EntityModel, bool):
84
+
85
+ async def create_or_update_entity(self, schema: EntitySchema) -> Tuple[EntityModel, bool]:
90
86
  """Create new entity or update existing one.
91
- if a new entity is created, the return value is (entity, True)
87
+ Returns: (entity, is_new) where is_new is True if a new entity was created
92
88
  """
93
-
94
89
  logger.debug(f"Creating or updating entity: {schema}")
95
90
 
96
91
  # Try to find existing entity using smart resolution
@@ -107,7 +102,7 @@ class EntityService(BaseService[EntityModel]):
107
102
  """Create a new entity and write to filesystem."""
108
103
  logger.debug(f"Creating entity: {schema.permalink}")
109
104
 
110
- # get file path
105
+ # Get file path and ensure it's a Path object
111
106
  file_path = Path(schema.file_path)
112
107
 
113
108
  if await self.file_service.exists(file_path):
@@ -127,11 +122,9 @@ class EntityService(BaseService[EntityModel]):
127
122
 
128
123
  # parse entity from file
129
124
  entity_markdown = await self.entity_parser.parse_file(file_path)
130
-
125
+
131
126
  # create entity
132
- created_entity = await self.create_entity_from_markdown(
133
- file_path, entity_markdown
134
- )
127
+ await self.create_entity_from_markdown(file_path, entity_markdown)
135
128
 
136
129
  # add relations
137
130
  entity = await self.update_entity_relations(file_path, entity_markdown)
@@ -139,12 +132,11 @@ class EntityService(BaseService[EntityModel]):
139
132
  # Set final checksum to mark complete
140
133
  return await self.repository.update(entity.id, {"checksum": checksum})
141
134
 
142
-
143
135
  async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> EntityModel:
144
136
  """Update an entity's content and metadata."""
145
137
  logger.debug(f"Updating entity with permalink: {entity.permalink}")
146
138
 
147
- # get file path
139
+ # Convert file path string to Path
148
140
  file_path = Path(entity.file_path)
149
141
 
150
142
  post = await schema_to_markdown(schema)
@@ -157,9 +149,7 @@ class EntityService(BaseService[EntityModel]):
157
149
  entity_markdown = await self.entity_parser.parse_file(file_path)
158
150
 
159
151
  # update entity in db
160
- entity = await self.update_entity_and_observations(
161
- file_path, entity_markdown
162
- )
152
+ entity = await self.update_entity_and_observations(file_path, entity_markdown)
163
153
 
164
154
  # add relations
165
155
  await self.update_entity_relations(file_path, entity_markdown)
@@ -187,10 +177,6 @@ class EntityService(BaseService[EntityModel]):
187
177
  logger.info(f"Entity not found: {permalink}")
188
178
  return True # Already deleted
189
179
 
190
- except Exception as e:
191
- logger.error(f"Failed to delete entity: {e}")
192
- raise
193
-
194
180
  async def get_by_permalink(self, permalink: str) -> EntityModel:
195
181
  """Get entity by type and name combination."""
196
182
  logger.debug(f"Getting entity by permalink: {permalink}")
@@ -199,32 +185,14 @@ class EntityService(BaseService[EntityModel]):
199
185
  raise EntityNotFoundError(f"Entity not found: {permalink}")
200
186
  return db_entity
201
187
 
202
- async def get_all(self) -> Sequence[EntityModel]:
203
- """Get all entities."""
204
- return await self.repository.find_all()
205
-
206
- async def get_entity_types(self) -> List[str]:
207
- """Get list of all distinct entity types in the system."""
208
- logger.debug("Getting all distinct entity types")
209
- return await self.repository.get_entity_types()
210
-
211
- async def list_entities(
212
- self,
213
- entity_type: Optional[str] = None,
214
- sort_by: Optional[str] = "updated_at",
215
- include_related: bool = False,
216
- ) -> Sequence[EntityModel]:
217
- """List entities with optional filtering and sorting."""
218
- logger.debug(f"Listing entities: type={entity_type} sort={sort_by}")
219
- return await self.repository.list_entities(entity_type=entity_type, sort_by=sort_by)
220
-
221
188
  async def get_entities_by_permalinks(self, permalinks: List[str]) -> Sequence[EntityModel]:
222
189
  """Get specific nodes and their relationships."""
223
190
  logger.debug(f"Getting entities permalinks: {permalinks}")
224
191
  return await self.repository.find_by_permalinks(permalinks)
225
192
 
226
- async def delete_entity_by_file_path(self, file_path):
227
- await self.repository.delete_by_file_path(file_path)
193
+ async def delete_entity_by_file_path(self, file_path: Union[str, Path]) -> None:
194
+ """Delete entity by file path."""
195
+ await self.repository.delete_by_file_path(str(file_path))
228
196
 
229
197
  async def create_entity_from_markdown(
230
198
  self, file_path: Path, markdown: EntityMarkdown
@@ -234,15 +202,15 @@ class EntityService(BaseService[EntityModel]):
234
202
  Creates the entity with null checksum to indicate sync not complete.
235
203
  Relations will be added in second pass.
236
204
  """
237
- logger.debug(f"Creating entity: {markdown.frontmatter.title}")
205
+ logger.debug(f"Creating entity: {markdown.frontmatter.title}")
238
206
  model = entity_model_from_markdown(file_path, markdown)
239
207
 
240
- # Mark as incomplete sync
208
+ # Mark as incomplete because we still need to add relations
241
209
  model.checksum = None
242
- return await self.add(model)
210
+ return await self.repository.add(model)
243
211
 
244
212
  async def update_entity_and_observations(
245
- self, file_path: Path | str, markdown: EntityMarkdown
213
+ self, file_path: Path, markdown: EntityMarkdown
246
214
  ) -> EntityModel:
247
215
  """Update entity fields and observations.
248
216
 
@@ -250,11 +218,8 @@ class EntityService(BaseService[EntityModel]):
250
218
  to indicate sync not complete.
251
219
  """
252
220
  logger.debug(f"Updating entity and observations: {file_path}")
253
- file_path = str(file_path)
254
221
 
255
- db_entity = await self.repository.get_by_file_path(file_path)
256
- if not db_entity:
257
- raise EntityNotFoundError(f"Entity not found: {file_path}")
222
+ db_entity = await self.repository.get_by_file_path(str(file_path))
258
223
 
259
224
  # Clear observations for entity
260
225
  await self.observation_repository.delete_by_fields(entity_id=db_entity.id)
@@ -277,9 +242,8 @@ class EntityService(BaseService[EntityModel]):
277
242
 
278
243
  # checksum value is None == not finished with sync
279
244
  db_entity.checksum = None
280
-
245
+
281
246
  # update entity
282
- # checksum value is None == not finished with sync
283
247
  return await self.repository.update(
284
248
  db_entity.id,
285
249
  db_entity,
@@ -287,14 +251,13 @@ class EntityService(BaseService[EntityModel]):
287
251
 
288
252
  async def update_entity_relations(
289
253
  self,
290
- file_path: Path | str,
254
+ file_path: Path,
291
255
  markdown: EntityMarkdown,
292
256
  ) -> EntityModel:
293
257
  """Update relations for entity"""
294
258
  logger.debug(f"Updating relations for entity: {file_path}")
295
259
 
296
- file_path = str(file_path)
297
- db_entity = await self.repository.get_by_file_path(file_path)
260
+ db_entity = await self.repository.get_by_file_path(str(file_path))
298
261
 
299
262
  # Clear existing relations first
300
263
  await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
@@ -328,4 +291,4 @@ class EntityService(BaseService[EntityModel]):
328
291
  )
329
292
  continue
330
293
 
331
- return await self.repository.get_by_file_path(file_path)
294
+ return await self.repository.get_by_file_path(str(file_path))
@@ -9,6 +9,7 @@ class EntityNotFoundError(Exception):
9
9
 
10
10
  pass
11
11
 
12
+
12
13
  class EntityCreationError(Exception):
13
14
  """Raised when an entity cannot be created"""
14
15
 
@@ -1,20 +1,23 @@
1
1
  """Service for file operations with checksum tracking."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import Optional, Tuple
4
+ from typing import Tuple, Union
5
5
 
6
6
  from loguru import logger
7
7
 
8
8
  from basic_memory import file_utils
9
9
  from basic_memory.markdown.markdown_processor import MarkdownProcessor
10
- from basic_memory.markdown.utils import entity_model_to_markdown
11
10
  from basic_memory.models import Entity as EntityModel
12
- from basic_memory.services.exceptions import FileOperationError
13
11
  from basic_memory.schemas import Entity as EntitySchema
12
+ from basic_memory.services.exceptions import FileOperationError
13
+
14
14
 
15
15
  class FileService:
16
- """
17
- Service for handling file operations.
16
+ """Service for handling file operations.
17
+
18
+ All paths are handled as Path objects internally. Strings are converted to
19
+ Path objects when passed in. Relative paths are assumed to be relative to
20
+ base_path.
18
21
 
19
22
  Features:
20
23
  - Consistent file writing with checksums
@@ -28,105 +31,66 @@ class FileService:
28
31
  base_path: Path,
29
32
  markdown_processor: MarkdownProcessor,
30
33
  ):
31
- self.base_path = base_path
34
+ self.base_path = base_path.resolve() # Get absolute path
32
35
  self.markdown_processor = markdown_processor
33
36
 
34
- def get_entity_path(self, entity: EntityModel| EntitySchema) -> Path:
35
- """Generate absolute filesystem path for entity."""
36
- return self.base_path / f"{entity.file_path}"
37
-
38
- async def write_entity_file(
39
- self,
40
- entity: EntityModel,
41
- content: Optional[str] = None,
42
- expected_checksum: Optional[str] = None,
43
- ) -> Tuple[Path, str]:
44
- """Write entity to filesystem and return path and checksum.
45
-
46
- Uses read->modify->write pattern:
47
- 1. Read existing file if it exists
48
- 2. Update with new content if provided
49
- 3. Write back atomically
37
+ def get_entity_path(self, entity: Union[EntityModel, EntitySchema]) -> Path:
38
+ """Generate absolute filesystem path for entity.
50
39
 
51
40
  Args:
52
- entity: Entity model to write
53
- content: Optional new content (preserves existing if None)
54
- expected_checksum: Optional checksum to verify file hasn't changed
41
+ entity: Entity model or schema with file_path attribute
55
42
 
56
43
  Returns:
57
- Tuple of (file path, new checksum)
58
-
59
- Raises:
60
- FileOperationError: If write fails
44
+ Absolute Path to the entity file
61
45
  """
62
- try:
63
- path = self.get_entity_path(entity)
64
-
65
- # Read current state if file exists
66
- if path.exists():
67
- # read the existing file
68
- existing_markdown = await self.markdown_processor.read_file(path)
69
-
70
- # if content is supplied use it or existing content
71
- content=content or existing_markdown.content
72
-
73
- # Create new file structure with provided content
74
- markdown = entity_model_to_markdown(entity, content=content)
75
-
76
- # Write back atomically
77
- checksum = await self.markdown_processor.write_file(
78
- path=path, markdown=markdown, expected_checksum=expected_checksum
79
- )
80
-
81
- return path, checksum
82
-
83
- except Exception as e:
84
- logger.exception(f"Failed to write entity file: {e}")
85
- raise FileOperationError(f"Failed to write entity file: {e}")
46
+ return self.base_path / entity.file_path
86
47
 
87
48
  async def read_entity_content(self, entity: EntityModel) -> str:
88
- """Get entity's content without frontmatter or structured sections (used to index for search)
49
+ """Get entity's content without frontmatter or structured sections.
50
+
51
+ Used to index for search. Returns raw content without frontmatter,
52
+ observations, or relations.
89
53
 
90
54
  Args:
91
55
  entity: Entity to read content for
92
56
 
93
57
  Returns:
94
- Raw content without frontmatter, observations, or relations
95
-
96
- Raises:
97
- FileOperationError: If entity file doesn't exist
58
+ Raw content string without metadata sections
98
59
  """
99
60
  logger.debug(f"Reading entity with permalink: {entity.permalink}")
100
61
 
101
- try:
102
- file_path = self.get_entity_path(entity)
103
- markdown = await self.markdown_processor.read_file(file_path)
104
- return markdown.content or ""
105
-
106
- except Exception as e:
107
- logger.error(f"Failed to read entity content: {e}")
108
- raise FileOperationError(f"Failed to read entity content: {e}")
62
+ file_path = self.get_entity_path(entity)
63
+ markdown = await self.markdown_processor.read_file(file_path)
64
+ return markdown.content or ""
109
65
 
110
66
  async def delete_entity_file(self, entity: EntityModel) -> None:
111
- """Delete entity file from filesystem."""
112
- try:
113
- path = self.get_entity_path(entity)
114
- await self.delete_file(path)
115
- except Exception as e:
116
- logger.error(f"Failed to delete entity file: {e}")
117
- raise FileOperationError(f"Failed to delete entity file: {e}")
67
+ """Delete entity file from filesystem.
68
+
69
+ Args:
70
+ entity: Entity model whose file should be deleted
118
71
 
119
- async def exists(self, path: Path) -> bool:
72
+ Raises:
73
+ FileOperationError: If deletion fails
120
74
  """
121
- Check if file exists at the provided path. If path is relative, it is assumed to be relative to base_path.
75
+ path = self.get_entity_path(entity)
76
+ await self.delete_file(path)
77
+
78
+ async def exists(self, path: Union[Path, str]) -> bool:
79
+ """Check if file exists at the provided path.
80
+
81
+ If path is relative, it is assumed to be relative to base_path.
122
82
 
123
83
  Args:
124
- path: Path to check
84
+ path: Path to check (Path object or string)
125
85
 
126
86
  Returns:
127
87
  True if file exists, False otherwise
88
+
89
+ Raises:
90
+ FileOperationError: If check fails
128
91
  """
129
92
  try:
93
+ path = Path(path)
130
94
  if path.is_absolute():
131
95
  return path.exists()
132
96
  else:
@@ -135,12 +99,14 @@ class FileService:
135
99
  logger.error(f"Failed to check file existence {path}: {e}")
136
100
  raise FileOperationError(f"Failed to check file existence: {e}")
137
101
 
138
- async def write_file(self, path: Path, content: str) -> str:
139
- """
140
- Write content to file and return checksum.
102
+ async def write_file(self, path: Union[Path, str], content: str) -> str:
103
+ """Write content to file and return checksum.
104
+
105
+ Handles both absolute and relative paths. Relative paths are resolved
106
+ against base_path.
141
107
 
142
108
  Args:
143
- path: Path where to write
109
+ path: Where to write (Path object or string)
144
110
  content: Content to write
145
111
 
146
112
  Returns:
@@ -149,30 +115,33 @@ class FileService:
149
115
  Raises:
150
116
  FileOperationError: If write fails
151
117
  """
152
-
153
- path = path if path.is_absolute() else self.base_path / path
118
+ path = Path(path)
119
+ full_path = path if path.is_absolute() else self.base_path / path
120
+
154
121
  try:
155
122
  # Ensure parent directory exists
156
- await file_utils.ensure_directory(path.parent)
123
+ await file_utils.ensure_directory(full_path.parent)
157
124
 
158
125
  # Write content atomically
159
- await file_utils.write_file_atomic(path, content)
126
+ await file_utils.write_file_atomic(full_path, content)
160
127
 
161
128
  # Compute and return checksum
162
129
  checksum = await file_utils.compute_checksum(content)
163
- logger.debug(f"wrote file: {path}, checksum: {checksum} content: \n{content}")
130
+ logger.debug(f"wrote file: {full_path}, checksum: {checksum}")
164
131
  return checksum
165
132
 
166
133
  except Exception as e:
167
- logger.error(f"Failed to write file {path}: {e}")
134
+ logger.error(f"Failed to write file {full_path}: {e}")
168
135
  raise FileOperationError(f"Failed to write file: {e}")
169
136
 
170
- async def read_file(self, path: Path) -> Tuple[str, str]:
171
- """
172
- Read file and compute checksum.
137
+ async def read_file(self, path: Union[Path, str]) -> Tuple[str, str]:
138
+ """Read file and compute checksum.
139
+
140
+ Handles both absolute and relative paths. Relative paths are resolved
141
+ against base_path.
173
142
 
174
143
  Args:
175
- path: Path to read
144
+ path: Path to read (Path object or string)
176
145
 
177
146
  Returns:
178
147
  Tuple of (content, checksum)
@@ -180,33 +149,28 @@ class FileService:
180
149
  Raises:
181
150
  FileOperationError: If read fails
182
151
  """
183
- path = path if path.is_absolute() else self.base_path / path
152
+ path = Path(path)
153
+ full_path = path if path.is_absolute() else self.base_path / path
154
+
184
155
  try:
185
156
  content = path.read_text()
186
157
  checksum = await file_utils.compute_checksum(content)
187
- logger.debug(f"read file: {path}, checksum: {checksum}")
158
+ logger.debug(f"read file: {full_path}, checksum: {checksum}")
188
159
  return content, checksum
189
160
 
190
161
  except Exception as e:
191
- logger.error(f"Failed to read file {path}: {e}")
162
+ logger.error(f"Failed to read file {full_path}: {e}")
192
163
  raise FileOperationError(f"Failed to read file: {e}")
193
164
 
194
- async def delete_file(self, path: Path) -> None:
195
- """
196
- Delete file if it exists.
165
+ async def delete_file(self, path: Union[Path, str]) -> None:
166
+ """Delete file if it exists.
197
167
 
198
- Args:
199
- path: Path to delete
168
+ Handles both absolute and relative paths. Relative paths are resolved
169
+ against base_path.
200
170
 
201
- Raises:
202
- FileOperationError: If deletion fails
171
+ Args:
172
+ path: Path to delete (Path object or string)
203
173
  """
204
- path = path if path.is_absolute() else self.base_path / path
205
- try:
206
- path.unlink(missing_ok=True)
207
- except Exception as e:
208
- logger.error(f"Failed to delete file {path}: {e}")
209
- raise FileOperationError(f"Failed to delete file: {e}")
210
-
211
- def path(self, path_string: str, absolute: bool = False):
212
- return Path( self.base_path / path_string ) if absolute else Path(path_string).relative_to(self.base_path)
174
+ path = Path(path)
175
+ full_path = path if path.is_absolute() else self.base_path / path
176
+ full_path.unlink(missing_ok=True)
@@ -5,9 +5,10 @@ from typing import Optional, Tuple, List
5
5
  from loguru import logger
6
6
 
7
7
  from basic_memory.repository.entity_repository import EntityRepository
8
+ from basic_memory.repository.search_repository import SearchIndexRow
8
9
  from basic_memory.services.search_service import SearchService
9
10
  from basic_memory.models import Entity
10
- from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
11
+ from basic_memory.schemas.search import SearchQuery, SearchItemType
11
12
 
12
13
 
13
14
  class LinkResolver:
@@ -45,18 +46,19 @@ class LinkResolver:
45
46
  return entity
46
47
 
47
48
  if use_search:
48
-
49
49
  # 3. Fall back to search for fuzzy matching on title if specified
50
50
  results = await self.search_service.search(
51
51
  query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]),
52
52
  )
53
-
53
+
54
54
  if results:
55
55
  # Look for best match
56
56
  best_match = self._select_best_match(clean_text, results)
57
- logger.debug(f"Selected best match from {len(results)} results: {best_match.permalink}")
57
+ logger.debug(
58
+ f"Selected best match from {len(results)} results: {best_match.permalink}"
59
+ )
58
60
  return await self.entity_repository.get_by_permalink(best_match.permalink)
59
-
61
+
60
62
  # if we couldn't find anything then return None
61
63
  return None
62
64
 
@@ -85,7 +87,7 @@ class LinkResolver:
85
87
 
86
88
  return text, alias
87
89
 
88
- def _select_best_match(self, search_text: str, results: List[SearchResult]) -> Entity:
90
+ def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> Entity:
89
91
  """Select best match from search results.
90
92
 
91
93
  Uses multiple criteria:
@@ -93,9 +95,6 @@ class LinkResolver:
93
95
  2. Word matches in path
94
96
  3. Overall search score
95
97
  """
96
- if not results:
97
- raise ValueError("Cannot select from empty results")
98
-
99
98
  # Get search terms for matching
100
99
  terms = search_text.lower().split()
101
100
 
@@ -104,6 +103,7 @@ class LinkResolver:
104
103
  for result in results:
105
104
  # Start with base score (lower is better)
106
105
  score = result.score
106
+ assert score is not None
107
107
 
108
108
  # Parse path components
109
109
  path_parts = result.permalink.lower().split("/")
@@ -1,5 +1,6 @@
1
1
  """Service for search operations."""
2
2
 
3
+ from datetime import datetime
3
4
  from typing import List, Optional, Set
4
5
 
5
6
  from fastapi import BackgroundTasks
@@ -8,9 +9,8 @@ from loguru import logger
8
9
  from basic_memory.models import Entity
9
10
  from basic_memory.repository import EntityRepository
10
11
  from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
11
- from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
12
+ from basic_memory.schemas.search import SearchQuery, SearchItemType
12
13
  from basic_memory.services import FileService
13
- from basic_memory.services.exceptions import FileOperationError
14
14
 
15
15
 
16
16
  class SearchService:
@@ -51,9 +51,7 @@ class SearchService:
51
51
 
52
52
  logger.info("Reindex complete")
53
53
 
54
- async def search(
55
- self, query: SearchQuery, context: Optional[List[str]] = None
56
- ) -> List[SearchResult]:
54
+ async def search(self, query: SearchQuery) -> List[SearchIndexRow]:
57
55
  """Search across all indexed content.
58
56
 
59
57
  Supports three modes:
@@ -67,6 +65,16 @@ class SearchService:
67
65
 
68
66
  logger.debug(f"Searching with query: {query}")
69
67
 
68
+ after_date = (
69
+ (
70
+ query.after_date
71
+ if isinstance(query.after_date, datetime)
72
+ else datetime.fromisoformat(query.after_date)
73
+ )
74
+ if query.after_date
75
+ else None
76
+ )
77
+
70
78
  # permalink search
71
79
  results = await self.repository.search(
72
80
  search_text=query.text,
@@ -75,12 +83,13 @@ class SearchService:
75
83
  title=query.title,
76
84
  types=query.types,
77
85
  entity_types=query.entity_types,
78
- after_date=query.after_date,
86
+ after_date=after_date,
79
87
  )
80
88
 
81
89
  return results
82
90
 
83
- def _generate_variants(self, text: str) -> Set[str]:
91
+ @staticmethod
92
+ def _generate_variants(text: str) -> Set[str]:
84
93
  """Generate text variants for better fuzzy matching.
85
94
 
86
95
  Creates variations of the text to improve match chances:
@@ -140,7 +149,6 @@ class SearchService:
140
149
  title_variants = self._generate_variants(entity.title)
141
150
  content_parts.extend(title_variants)
142
151
 
143
- # TODO should we do something to content on indexing?
144
152
  content = await self.file_service.read_entity_content(entity)
145
153
  if content:
146
154
  content_parts.append(content)
@@ -162,8 +170,8 @@ class SearchService:
162
170
  metadata={
163
171
  "entity_type": entity.entity_type,
164
172
  },
165
- created_at=entity.created_at.isoformat(),
166
- updated_at=entity.updated_at.isoformat(),
173
+ created_at=entity.created_at,
174
+ updated_at=entity.updated_at,
167
175
  )
168
176
  )
169
177
 
@@ -183,8 +191,8 @@ class SearchService:
183
191
  metadata={
184
192
  "tags": obs.tags,
185
193
  },
186
- created_at=entity.created_at.isoformat(),
187
- updated_at=entity.updated_at.isoformat(),
194
+ created_at=entity.created_at,
195
+ updated_at=entity.updated_at,
188
196
  )
189
197
  )
190
198
 
@@ -201,15 +209,14 @@ class SearchService:
201
209
  SearchIndexRow(
202
210
  id=rel.id,
203
211
  title=relation_title,
204
- content=rel.context or "",
205
212
  permalink=rel.permalink,
206
213
  file_path=entity.file_path,
207
214
  type=SearchItemType.RELATION.value,
208
215
  from_id=rel.from_id,
209
216
  to_id=rel.to_id,
210
217
  relation_type=rel.relation_type,
211
- created_at=entity.created_at.isoformat(),
212
- updated_at=entity.updated_at.isoformat(),
218
+ created_at=entity.created_at,
219
+ updated_at=entity.updated_at,
213
220
  )
214
221
  )
215
222