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
@@ -1,15 +1,19 @@
1
1
  """Service for file operations with checksum tracking."""
2
2
 
3
+ import mimetypes
4
+ from os import stat_result
3
5
  from pathlib import Path
4
- from typing import Tuple, Union
6
+ from typing import Any, Dict, Tuple, Union
5
7
 
6
8
  from loguru import logger
7
9
 
8
10
  from basic_memory import file_utils
11
+ from basic_memory.file_utils import FileError
9
12
  from basic_memory.markdown.markdown_processor import MarkdownProcessor
10
13
  from basic_memory.models import Entity as EntityModel
11
14
  from basic_memory.schemas import Entity as EntitySchema
12
15
  from basic_memory.services.exceptions import FileOperationError
16
+ from basic_memory.utils import FilePath
13
17
 
14
18
 
15
19
  class FileService:
@@ -57,7 +61,7 @@ class FileService:
57
61
  Returns:
58
62
  Raw content string without metadata sections
59
63
  """
60
- logger.debug(f"Reading entity with permalink: {entity.permalink}")
64
+ logger.debug("Reading entity content", entity_id=entity.id, permalink=entity.permalink)
61
65
 
62
66
  file_path = self.get_entity_path(entity)
63
67
  markdown = await self.markdown_processor.read_file(file_path)
@@ -75,13 +79,13 @@ class FileService:
75
79
  path = self.get_entity_path(entity)
76
80
  await self.delete_file(path)
77
81
 
78
- async def exists(self, path: Union[Path, str]) -> bool:
82
+ async def exists(self, path: FilePath) -> bool:
79
83
  """Check if file exists at the provided path.
80
84
 
81
85
  If path is relative, it is assumed to be relative to base_path.
82
86
 
83
87
  Args:
84
- path: Path to check (Path object or string)
88
+ path: Path to check (Path or string)
85
89
 
86
90
  Returns:
87
91
  True if file exists, False otherwise
@@ -90,23 +94,25 @@ class FileService:
90
94
  FileOperationError: If check fails
91
95
  """
92
96
  try:
93
- path = Path(path)
94
- if path.is_absolute():
95
- return path.exists()
97
+ # Convert string to Path if needed
98
+ path_obj = Path(path) if isinstance(path, str) else path
99
+
100
+ if path_obj.is_absolute():
101
+ return path_obj.exists()
96
102
  else:
97
- return (self.base_path / path).exists()
103
+ return (self.base_path / path_obj).exists()
98
104
  except Exception as e:
99
- logger.error(f"Failed to check file existence {path}: {e}")
105
+ logger.error("Failed to check file existence", path=str(path), error=str(e))
100
106
  raise FileOperationError(f"Failed to check file existence: {e}")
101
107
 
102
- async def write_file(self, path: Union[Path, str], content: str) -> str:
108
+ async def write_file(self, path: FilePath, content: str) -> str:
103
109
  """Write content to file and return checksum.
104
110
 
105
111
  Handles both absolute and relative paths. Relative paths are resolved
106
112
  against base_path.
107
113
 
108
114
  Args:
109
- path: Where to write (Path object or string)
115
+ path: Where to write (Path or string)
110
116
  content: Content to write
111
117
 
112
118
  Returns:
@@ -115,33 +121,43 @@ class FileService:
115
121
  Raises:
116
122
  FileOperationError: If write fails
117
123
  """
118
- path = Path(path)
119
- full_path = path if path.is_absolute() else self.base_path / path
124
+ # Convert string to Path if needed
125
+ path_obj = Path(path) if isinstance(path, str) else path
126
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
120
127
 
121
128
  try:
122
129
  # Ensure parent directory exists
123
130
  await file_utils.ensure_directory(full_path.parent)
124
131
 
125
132
  # Write content atomically
133
+ logger.info(
134
+ "Writing file",
135
+ operation="write_file",
136
+ path=str(full_path),
137
+ content_length=len(content),
138
+ is_markdown=full_path.suffix.lower() == ".md",
139
+ )
140
+
126
141
  await file_utils.write_file_atomic(full_path, content)
127
142
 
128
143
  # Compute and return checksum
129
144
  checksum = await file_utils.compute_checksum(content)
130
- logger.debug(f"wrote file: {full_path}, checksum: {checksum}")
145
+ logger.debug("File write completed", path=str(full_path), checksum=checksum)
131
146
  return checksum
132
147
 
133
148
  except Exception as e:
134
- logger.error(f"Failed to write file {full_path}: {e}")
149
+ logger.exception("File write error", path=str(full_path), error=str(e))
135
150
  raise FileOperationError(f"Failed to write file: {e}")
136
151
 
137
- async def read_file(self, path: Union[Path, str]) -> Tuple[str, str]:
152
+ # TODO remove read_file
153
+ async def read_file(self, path: FilePath) -> Tuple[str, str]:
138
154
  """Read file and compute checksum.
139
155
 
140
156
  Handles both absolute and relative paths. Relative paths are resolved
141
157
  against base_path.
142
158
 
143
159
  Args:
144
- path: Path to read (Path object or string)
160
+ path: Path to read (Path or string)
145
161
 
146
162
  Returns:
147
163
  Tuple of (content, checksum)
@@ -149,28 +165,131 @@ class FileService:
149
165
  Raises:
150
166
  FileOperationError: If read fails
151
167
  """
152
- path = Path(path)
153
- full_path = path if path.is_absolute() else self.base_path / path
168
+ # Convert string to Path if needed
169
+ path_obj = Path(path) if isinstance(path, str) else path
170
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
154
171
 
155
172
  try:
156
- content = path.read_text()
173
+ logger.debug("Reading file", operation="read_file", path=str(full_path))
174
+
175
+ content = full_path.read_text()
157
176
  checksum = await file_utils.compute_checksum(content)
158
- logger.debug(f"read file: {full_path}, checksum: {checksum}")
177
+
178
+ logger.debug(
179
+ "File read completed",
180
+ path=str(full_path),
181
+ checksum=checksum,
182
+ content_length=len(content),
183
+ )
159
184
  return content, checksum
160
185
 
161
186
  except Exception as e:
162
- logger.error(f"Failed to read file {full_path}: {e}")
187
+ logger.exception("File read error", path=str(full_path), error=str(e))
163
188
  raise FileOperationError(f"Failed to read file: {e}")
164
189
 
165
- async def delete_file(self, path: Union[Path, str]) -> None:
190
+ async def delete_file(self, path: FilePath) -> None:
166
191
  """Delete file if it exists.
167
192
 
168
193
  Handles both absolute and relative paths. Relative paths are resolved
169
194
  against base_path.
170
195
 
171
196
  Args:
172
- path: Path to delete (Path object or string)
197
+ path: Path to delete (Path or string)
173
198
  """
174
- path = Path(path)
175
- full_path = path if path.is_absolute() else self.base_path / path
199
+ # Convert string to Path if needed
200
+ path_obj = Path(path) if isinstance(path, str) else path
201
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
176
202
  full_path.unlink(missing_ok=True)
203
+
204
+ async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
205
+ """
206
+ Update frontmatter fields in a file while preserving all content.
207
+
208
+ Args:
209
+ path: Path to the file (Path or string)
210
+ updates: Dictionary of frontmatter fields to update
211
+
212
+ Returns:
213
+ Checksum of updated file
214
+ """
215
+ # Convert string to Path if needed
216
+ path_obj = Path(path) if isinstance(path, str) else path
217
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
218
+ return await file_utils.update_frontmatter(full_path, updates)
219
+
220
+ async def compute_checksum(self, path: FilePath) -> str:
221
+ """Compute checksum for a file.
222
+
223
+ Args:
224
+ path: Path to the file (Path or string)
225
+
226
+ Returns:
227
+ Checksum of the file content
228
+
229
+ Raises:
230
+ FileError: If checksum computation fails
231
+ """
232
+ # Convert string to Path if needed
233
+ path_obj = Path(path) if isinstance(path, str) else path
234
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
235
+
236
+ try:
237
+ if self.is_markdown(path):
238
+ # read str
239
+ content = full_path.read_text()
240
+ else:
241
+ # read bytes
242
+ content = full_path.read_bytes()
243
+ return await file_utils.compute_checksum(content)
244
+
245
+ except Exception as e: # pragma: no cover
246
+ logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
247
+ raise FileError(f"Failed to compute checksum for {path}: {e}")
248
+
249
+ def file_stats(self, path: FilePath) -> stat_result:
250
+ """Return file stats for a given path.
251
+
252
+ Args:
253
+ path: Path to the file (Path or string)
254
+
255
+ Returns:
256
+ File statistics
257
+ """
258
+ # Convert string to Path if needed
259
+ path_obj = Path(path) if isinstance(path, str) else path
260
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
261
+ # get file timestamps
262
+ return full_path.stat()
263
+
264
+ def content_type(self, path: FilePath) -> str:
265
+ """Return content_type for a given path.
266
+
267
+ Args:
268
+ path: Path to the file (Path or string)
269
+
270
+ Returns:
271
+ MIME type of the file
272
+ """
273
+ # Convert string to Path if needed
274
+ path_obj = Path(path) if isinstance(path, str) else path
275
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
276
+ # get file timestamps
277
+ mime_type, _ = mimetypes.guess_type(full_path.name)
278
+
279
+ # .canvas files are json
280
+ if full_path.suffix == ".canvas":
281
+ mime_type = "application/json"
282
+
283
+ content_type = mime_type or "text/plain"
284
+ return content_type
285
+
286
+ def is_markdown(self, path: FilePath) -> bool:
287
+ """Check if a file is a markdown file.
288
+
289
+ Args:
290
+ path: Path to the file (Path or string)
291
+
292
+ Returns:
293
+ True if the file is a markdown file, False otherwise
294
+ """
295
+ return self.content_type(path) == "text/markdown"
@@ -1,14 +1,13 @@
1
1
  """Service for resolving markdown links to permalinks."""
2
2
 
3
- from typing import Optional, Tuple, List
3
+ from typing import Optional, Tuple
4
4
 
5
5
  from loguru import logger
6
6
 
7
- from basic_memory.repository.entity_repository import EntityRepository
8
- from basic_memory.repository.search_repository import SearchIndexRow
9
- from basic_memory.services.search_service import SearchService
10
7
  from basic_memory.models import Entity
8
+ from basic_memory.repository.entity_repository import EntityRepository
11
9
  from basic_memory.schemas.search import SearchQuery, SearchItemType
10
+ from basic_memory.services.search_service import SearchService
12
11
 
13
12
 
14
13
  class LinkResolver:
@@ -41,8 +40,9 @@ class LinkResolver:
41
40
  return entity
42
41
 
43
42
  # 2. Try exact title match
44
- entity = await self.entity_repository.get_by_title(clean_text)
45
- if entity:
43
+ found = await self.entity_repository.get_by_title(clean_text)
44
+ if found and len(found) == 1:
45
+ entity = found[0]
46
46
  logger.debug(f"Found title match: {entity.title}")
47
47
  return entity
48
48
 
@@ -54,11 +54,12 @@ class LinkResolver:
54
54
 
55
55
  if results:
56
56
  # Look for best match
57
- best_match = self._select_best_match(clean_text, results)
57
+ best_match = min(results, key=lambda x: x.score) # pyright: ignore
58
58
  logger.debug(
59
59
  f"Selected best match from {len(results)} results: {best_match.permalink}"
60
60
  )
61
- return await self.entity_repository.get_by_permalink(best_match.permalink)
61
+ if best_match.permalink:
62
+ return await self.entity_repository.get_by_permalink(best_match.permalink)
62
63
 
63
64
  # if we couldn't find anything then return None
64
65
  return None
@@ -87,41 +88,3 @@ class LinkResolver:
87
88
  alias = alias.strip()
88
89
 
89
90
  return text, alias
90
-
91
- def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> SearchIndexRow:
92
- """Select best match from search results.
93
-
94
- Uses multiple criteria:
95
- 1. Word matches in title field
96
- 2. Word matches in path
97
- 3. Overall search score
98
- """
99
- # Get search terms for matching
100
- terms = search_text.lower().split()
101
-
102
- # Score each result
103
- scored_results = []
104
- for result in results:
105
- # Start with base score (lower is better)
106
- score = result.score
107
- assert score is not None
108
-
109
- # Parse path components
110
- path_parts = result.permalink.lower().split("/")
111
- last_part = path_parts[-1] if path_parts else ""
112
-
113
- # Title word match boosts
114
- term_matches = [term for term in terms if term in last_part]
115
- if term_matches:
116
- score *= 0.5 # Boost for each matching term
117
-
118
- # Exact title match is best
119
- if last_part == search_text.lower():
120
- score *= 0.2
121
-
122
- scored_results.append((score, result))
123
-
124
- # Sort by score (lowest first) and return best
125
- scored_results.sort(key=lambda x: x[0], reverse=True)
126
-
127
- return scored_results[0][1]
@@ -3,8 +3,10 @@
3
3
  from datetime import datetime
4
4
  from typing import List, Optional, Set
5
5
 
6
+ from dateparser import parse
6
7
  from fastapi import BackgroundTasks
7
8
  from loguru import logger
9
+ from sqlalchemy import text
8
10
 
9
11
  from basic_memory.models import Entity
10
12
  from basic_memory.repository import EntityRepository
@@ -38,9 +40,10 @@ class SearchService:
38
40
 
39
41
  async def reindex_all(self, background_tasks: Optional[BackgroundTasks] = None) -> None:
40
42
  """Reindex all content from database."""
41
- logger.info("Starting full reindex")
42
43
 
44
+ logger.info("Starting full reindex")
43
45
  # Clear and recreate search index
46
+ await self.repository.execute_query(text("DROP TABLE IF EXISTS search_index"), params={})
44
47
  await self.init_search_index()
45
48
 
46
49
  # Reindex all entities
@@ -69,7 +72,7 @@ class SearchService:
69
72
  (
70
73
  query.after_date
71
74
  if isinstance(query.after_date, datetime)
72
- else datetime.fromisoformat(query.after_date)
75
+ else parse(query.after_date)
73
76
  )
74
77
  if query.after_date
75
78
  else None
@@ -118,6 +121,47 @@ class SearchService:
118
121
  self,
119
122
  entity: Entity,
120
123
  background_tasks: Optional[BackgroundTasks] = None,
124
+ ) -> None:
125
+ if background_tasks:
126
+ background_tasks.add_task(self.index_entity_data, entity)
127
+ else:
128
+ await self.index_entity_data(entity)
129
+
130
+ async def index_entity_data(
131
+ self,
132
+ entity: Entity,
133
+ ) -> None:
134
+ # delete all search index data associated with entity
135
+ await self.repository.delete_by_entity_id(entity_id=entity.id)
136
+
137
+ # reindex
138
+ await self.index_entity_markdown(
139
+ entity
140
+ ) if entity.is_markdown else await self.index_entity_file(entity)
141
+
142
+ async def index_entity_file(
143
+ self,
144
+ entity: Entity,
145
+ ) -> None:
146
+ # Index entity file with no content
147
+ await self.repository.index_item(
148
+ SearchIndexRow(
149
+ id=entity.id,
150
+ entity_id=entity.id,
151
+ type=SearchItemType.ENTITY.value,
152
+ title=entity.title,
153
+ file_path=entity.file_path,
154
+ metadata={
155
+ "entity_type": entity.entity_type,
156
+ },
157
+ created_at=entity.created_at,
158
+ updated_at=entity.updated_at,
159
+ )
160
+ )
161
+
162
+ async def index_entity_markdown(
163
+ self,
164
+ entity: Entity,
121
165
  ) -> None:
122
166
  """Index an entity and all its observations and relations.
123
167
 
@@ -136,29 +180,43 @@ class SearchService:
136
180
 
137
181
  Each type gets its own row in the search index with appropriate metadata.
138
182
  """
139
- if background_tasks:
140
- background_tasks.add_task(self.index_entity_data, entity)
141
- else:
142
- await self.index_entity_data(entity)
143
183
 
144
- async def index_entity_data(
145
- self,
146
- entity: Entity,
147
- ) -> None:
148
- """Actually perform the indexing."""
184
+ if entity.permalink is None: # pragma: no cover
185
+ logger.error(
186
+ "Missing permalink for markdown entity",
187
+ entity_id=entity.id,
188
+ title=entity.title,
189
+ file_path=entity.file_path,
190
+ )
191
+ raise ValueError(
192
+ f"Entity permalink should not be None for markdown entity: {entity.id} ({entity.title})"
193
+ )
149
194
 
150
- content_parts = []
195
+ content_stems = []
196
+ content_snippet = ""
151
197
  title_variants = self._generate_variants(entity.title)
152
- content_parts.extend(title_variants)
198
+ content_stems.extend(title_variants)
153
199
 
154
200
  content = await self.file_service.read_entity_content(entity)
155
201
  if content:
156
- content_parts.append(content)
202
+ content_stems.append(content)
203
+ content_snippet = f"{content[:250]}"
157
204
 
158
- content_parts.extend(self._generate_variants(entity.permalink))
159
- content_parts.extend(self._generate_variants(entity.file_path))
205
+ content_stems.extend(self._generate_variants(entity.permalink))
206
+ content_stems.extend(self._generate_variants(entity.file_path))
160
207
 
161
- entity_content = "\n".join(p for p in content_parts if p and p.strip())
208
+ entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
209
+
210
+ if entity.permalink is None: # pragma: no cover
211
+ logger.error(
212
+ "Missing permalink for markdown entity",
213
+ entity_id=entity.id,
214
+ title=entity.title,
215
+ file_path=entity.file_path,
216
+ )
217
+ raise ValueError(
218
+ f"Entity permalink should not be None for markdown entity: {entity.id} ({entity.title})"
219
+ )
162
220
 
163
221
  # Index entity
164
222
  await self.repository.index_item(
@@ -166,9 +224,11 @@ class SearchService:
166
224
  id=entity.id,
167
225
  type=SearchItemType.ENTITY.value,
168
226
  title=entity.title,
169
- content=entity_content,
227
+ content_stems=entity_content_stems,
228
+ content_snippet=content_snippet,
170
229
  permalink=entity.permalink,
171
230
  file_path=entity.file_path,
231
+ entity_id=entity.id,
172
232
  metadata={
173
233
  "entity_type": entity.entity_type,
174
234
  },
@@ -180,12 +240,16 @@ class SearchService:
180
240
  # Index each observation with permalink
181
241
  for obs in entity.observations:
182
242
  # Index with parent entity's file path since that's where it's defined
243
+ obs_content_stems = "\n".join(
244
+ p for p in self._generate_variants(obs.content) if p and p.strip()
245
+ )
183
246
  await self.repository.index_item(
184
247
  SearchIndexRow(
185
248
  id=obs.id,
186
249
  type=SearchItemType.OBSERVATION.value,
187
- title=f"{obs.category}: {obs.content[:50]}...",
188
- content=obs.content,
250
+ title=f"{obs.category}: {obs.content[:100]}...",
251
+ content_stems=obs_content_stems,
252
+ content_snippet=obs.content,
189
253
  permalink=obs.permalink,
190
254
  file_path=entity.file_path,
191
255
  category=obs.category,
@@ -207,13 +271,18 @@ class SearchService:
207
271
  else f"{rel.from_entity.title}"
208
272
  )
209
273
 
274
+ rel_content_stems = "\n".join(
275
+ p for p in self._generate_variants(relation_title) if p and p.strip()
276
+ )
210
277
  await self.repository.index_item(
211
278
  SearchIndexRow(
212
279
  id=rel.id,
213
280
  title=relation_title,
214
281
  permalink=rel.permalink,
282
+ content_stems=rel_content_stems,
215
283
  file_path=entity.file_path,
216
284
  type=SearchItemType.RELATION.value,
285
+ entity_id=entity.id,
217
286
  from_id=rel.from_id,
218
287
  to_id=rel.to_id,
219
288
  relation_type=rel.relation_type,
@@ -222,6 +291,10 @@ class SearchService:
222
291
  )
223
292
  )
224
293
 
225
- async def delete_by_permalink(self, path_id: str):
294
+ async def delete_by_permalink(self, permalink: str):
295
+ """Delete an item from the search index."""
296
+ await self.repository.delete_by_permalink(permalink)
297
+
298
+ async def delete_by_entity_id(self, entity_id: int):
226
299
  """Delete an item from the search index."""
227
- await self.repository.delete_by_permalink(path_id)
300
+ await self.repository.delete_by_entity_id(entity_id)
@@ -1,5 +1,6 @@
1
- from .file_change_scanner import FileChangeScanner
1
+ """Basic Memory sync services."""
2
+
2
3
  from .sync_service import SyncService
3
4
  from .watch_service import WatchService
4
5
 
5
- __all__ = ["SyncService", "FileChangeScanner", "WatchService"]
6
+ __all__ = ["SyncService", "WatchService"]