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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +23 -1
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +106 -0
- basic_memory/api/app.py +9 -10
- basic_memory/api/routers/__init__.py +2 -1
- basic_memory/api/routers/knowledge_router.py +31 -5
- basic_memory/api/routers/memory_router.py +18 -17
- basic_memory/api/routers/project_info_router.py +275 -0
- basic_memory/api/routers/resource_router.py +105 -4
- basic_memory/api/routers/search_router.py +22 -4
- basic_memory/cli/app.py +54 -5
- basic_memory/cli/commands/__init__.py +15 -2
- basic_memory/cli/commands/db.py +9 -13
- basic_memory/cli/commands/import_chatgpt.py +26 -30
- basic_memory/cli/commands/import_claude_conversations.py +27 -29
- basic_memory/cli/commands/import_claude_projects.py +29 -31
- basic_memory/cli/commands/import_memory_json.py +26 -28
- basic_memory/cli/commands/mcp.py +7 -1
- basic_memory/cli/commands/project.py +119 -0
- basic_memory/cli/commands/project_info.py +167 -0
- basic_memory/cli/commands/status.py +14 -28
- basic_memory/cli/commands/sync.py +63 -22
- basic_memory/cli/commands/tool.py +253 -0
- basic_memory/cli/main.py +39 -1
- basic_memory/config.py +166 -4
- basic_memory/db.py +19 -4
- basic_memory/deps.py +10 -3
- basic_memory/file_utils.py +37 -19
- basic_memory/markdown/entity_parser.py +3 -3
- basic_memory/markdown/utils.py +5 -0
- basic_memory/mcp/async_client.py +1 -1
- basic_memory/mcp/main.py +24 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +26 -0
- basic_memory/mcp/prompts/continue_conversation.py +111 -0
- basic_memory/mcp/prompts/recent_activity.py +88 -0
- basic_memory/mcp/prompts/search.py +182 -0
- basic_memory/mcp/prompts/utils.py +155 -0
- basic_memory/mcp/server.py +2 -6
- basic_memory/mcp/tools/__init__.py +12 -21
- basic_memory/mcp/tools/build_context.py +85 -0
- basic_memory/mcp/tools/canvas.py +97 -0
- basic_memory/mcp/tools/delete_note.py +28 -0
- basic_memory/mcp/tools/project_info.py +51 -0
- basic_memory/mcp/tools/read_content.py +229 -0
- basic_memory/mcp/tools/read_note.py +190 -0
- basic_memory/mcp/tools/recent_activity.py +100 -0
- basic_memory/mcp/tools/search.py +56 -17
- basic_memory/mcp/tools/utils.py +245 -16
- basic_memory/mcp/tools/write_note.py +124 -0
- basic_memory/models/knowledge.py +27 -11
- basic_memory/models/search.py +2 -1
- basic_memory/repository/entity_repository.py +3 -2
- basic_memory/repository/project_info_repository.py +9 -0
- basic_memory/repository/repository.py +24 -7
- basic_memory/repository/search_repository.py +47 -14
- basic_memory/schemas/__init__.py +10 -9
- basic_memory/schemas/base.py +4 -1
- basic_memory/schemas/memory.py +14 -4
- basic_memory/schemas/project_info.py +96 -0
- basic_memory/schemas/search.py +29 -33
- basic_memory/services/context_service.py +3 -3
- basic_memory/services/entity_service.py +26 -13
- basic_memory/services/file_service.py +145 -26
- basic_memory/services/link_resolver.py +9 -46
- basic_memory/services/search_service.py +95 -22
- basic_memory/sync/__init__.py +3 -2
- basic_memory/sync/sync_service.py +523 -117
- basic_memory/sync/watch_service.py +258 -132
- basic_memory/utils.py +51 -36
- basic_memory-0.9.0.dist-info/METADATA +736 -0
- basic_memory-0.9.0.dist-info/RECORD +99 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/tools.py +0 -157
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -170
- basic_memory/mcp/tools/notes.py +0 -202
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -31
- basic_memory-0.7.0.dist-info/METADATA +0 -378
- basic_memory-0.7.0.dist-info/RECORD +0 -82
- {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.9.0.dist-info}/entry_points.txt +0 -0
- {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(
|
|
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:
|
|
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
|
|
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
|
-
|
|
94
|
-
if path
|
|
95
|
-
|
|
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 /
|
|
103
|
+
return (self.base_path / path_obj).exists()
|
|
98
104
|
except Exception as e:
|
|
99
|
-
logger.error(
|
|
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:
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
197
|
+
path: Path to delete (Path or string)
|
|
173
198
|
"""
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
if
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
195
|
+
content_stems = []
|
|
196
|
+
content_snippet = ""
|
|
151
197
|
title_variants = self._generate_variants(entity.title)
|
|
152
|
-
|
|
198
|
+
content_stems.extend(title_variants)
|
|
153
199
|
|
|
154
200
|
content = await self.file_service.read_entity_content(entity)
|
|
155
201
|
if content:
|
|
156
|
-
|
|
202
|
+
content_stems.append(content)
|
|
203
|
+
content_snippet = f"{content[:250]}"
|
|
157
204
|
|
|
158
|
-
|
|
159
|
-
|
|
205
|
+
content_stems.extend(self._generate_variants(entity.permalink))
|
|
206
|
+
content_stems.extend(self._generate_variants(entity.file_path))
|
|
160
207
|
|
|
161
|
-
|
|
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
|
-
|
|
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[:
|
|
188
|
-
|
|
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,
|
|
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.
|
|
300
|
+
await self.repository.delete_by_entity_id(entity_id)
|
basic_memory/sync/__init__.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
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", "
|
|
6
|
+
__all__ = ["SyncService", "WatchService"]
|