basic-memory 0.0.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 (73) hide show
  1. basic_memory/__init__.py +3 -0
  2. basic_memory/api/__init__.py +4 -0
  3. basic_memory/api/app.py +42 -0
  4. basic_memory/api/routers/__init__.py +8 -0
  5. basic_memory/api/routers/knowledge_router.py +168 -0
  6. basic_memory/api/routers/memory_router.py +123 -0
  7. basic_memory/api/routers/resource_router.py +34 -0
  8. basic_memory/api/routers/search_router.py +34 -0
  9. basic_memory/cli/__init__.py +1 -0
  10. basic_memory/cli/app.py +4 -0
  11. basic_memory/cli/commands/__init__.py +9 -0
  12. basic_memory/cli/commands/init.py +38 -0
  13. basic_memory/cli/commands/status.py +152 -0
  14. basic_memory/cli/commands/sync.py +254 -0
  15. basic_memory/cli/main.py +48 -0
  16. basic_memory/config.py +53 -0
  17. basic_memory/db.py +135 -0
  18. basic_memory/deps.py +182 -0
  19. basic_memory/file_utils.py +248 -0
  20. basic_memory/markdown/__init__.py +19 -0
  21. basic_memory/markdown/entity_parser.py +137 -0
  22. basic_memory/markdown/markdown_processor.py +153 -0
  23. basic_memory/markdown/plugins.py +236 -0
  24. basic_memory/markdown/schemas.py +73 -0
  25. basic_memory/markdown/utils.py +144 -0
  26. basic_memory/mcp/__init__.py +1 -0
  27. basic_memory/mcp/async_client.py +10 -0
  28. basic_memory/mcp/main.py +21 -0
  29. basic_memory/mcp/server.py +39 -0
  30. basic_memory/mcp/tools/__init__.py +34 -0
  31. basic_memory/mcp/tools/ai_edit.py +84 -0
  32. basic_memory/mcp/tools/knowledge.py +56 -0
  33. basic_memory/mcp/tools/memory.py +142 -0
  34. basic_memory/mcp/tools/notes.py +122 -0
  35. basic_memory/mcp/tools/search.py +28 -0
  36. basic_memory/mcp/tools/utils.py +154 -0
  37. basic_memory/models/__init__.py +12 -0
  38. basic_memory/models/base.py +9 -0
  39. basic_memory/models/knowledge.py +204 -0
  40. basic_memory/models/search.py +34 -0
  41. basic_memory/repository/__init__.py +7 -0
  42. basic_memory/repository/entity_repository.py +156 -0
  43. basic_memory/repository/observation_repository.py +40 -0
  44. basic_memory/repository/relation_repository.py +78 -0
  45. basic_memory/repository/repository.py +303 -0
  46. basic_memory/repository/search_repository.py +259 -0
  47. basic_memory/schemas/__init__.py +73 -0
  48. basic_memory/schemas/base.py +216 -0
  49. basic_memory/schemas/delete.py +38 -0
  50. basic_memory/schemas/discovery.py +25 -0
  51. basic_memory/schemas/memory.py +111 -0
  52. basic_memory/schemas/request.py +77 -0
  53. basic_memory/schemas/response.py +220 -0
  54. basic_memory/schemas/search.py +117 -0
  55. basic_memory/services/__init__.py +11 -0
  56. basic_memory/services/context_service.py +274 -0
  57. basic_memory/services/entity_service.py +281 -0
  58. basic_memory/services/exceptions.py +15 -0
  59. basic_memory/services/file_service.py +213 -0
  60. basic_memory/services/link_resolver.py +126 -0
  61. basic_memory/services/search_service.py +218 -0
  62. basic_memory/services/service.py +36 -0
  63. basic_memory/sync/__init__.py +5 -0
  64. basic_memory/sync/file_change_scanner.py +162 -0
  65. basic_memory/sync/sync_service.py +140 -0
  66. basic_memory/sync/utils.py +66 -0
  67. basic_memory/sync/watch_service.py +197 -0
  68. basic_memory/utils.py +78 -0
  69. basic_memory-0.0.0.dist-info/METADATA +71 -0
  70. basic_memory-0.0.0.dist-info/RECORD +73 -0
  71. basic_memory-0.0.0.dist-info/WHEEL +4 -0
  72. basic_memory-0.0.0.dist-info/entry_points.txt +2 -0
  73. basic_memory-0.0.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,213 @@
1
+ """Service for file operations with checksum tracking."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional, Tuple
5
+
6
+ from loguru import logger
7
+
8
+ from basic_memory import file_utils
9
+ from basic_memory.markdown.markdown_processor import MarkdownProcessor
10
+ from basic_memory.markdown.utils import entity_model_to_markdown
11
+ from basic_memory.models import Entity as EntityModel
12
+ from basic_memory.services.exceptions import FileOperationError
13
+ from basic_memory.schemas import Entity as EntitySchema
14
+
15
+ class FileService:
16
+ """
17
+ Service for handling file operations.
18
+
19
+ Features:
20
+ - Consistent file writing with checksums
21
+ - Frontmatter management
22
+ - Atomic operations
23
+ - Error handling
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ base_path: Path,
29
+ markdown_processor: MarkdownProcessor,
30
+ ):
31
+ self.base_path = base_path
32
+ self.markdown_processor = markdown_processor
33
+
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
+ # TODO move to tests
39
+ async def write_entity_file(
40
+ self,
41
+ entity: EntityModel,
42
+ content: Optional[str] = None,
43
+ expected_checksum: Optional[str] = None,
44
+ ) -> Tuple[Path, str]:
45
+ """Write entity to filesystem and return path and checksum.
46
+
47
+ Uses read->modify->write pattern:
48
+ 1. Read existing file if it exists
49
+ 2. Update with new content if provided
50
+ 3. Write back atomically
51
+
52
+ Args:
53
+ entity: Entity model to write
54
+ content: Optional new content (preserves existing if None)
55
+ expected_checksum: Optional checksum to verify file hasn't changed
56
+
57
+ Returns:
58
+ Tuple of (file path, new checksum)
59
+
60
+ Raises:
61
+ FileOperationError: If write fails
62
+ """
63
+ try:
64
+ path = self.get_entity_path(entity)
65
+
66
+ # Read current state if file exists
67
+ if path.exists():
68
+ # read the existing file
69
+ existing_markdown = await self.markdown_processor.read_file(path)
70
+
71
+ # if content is supplied use it or existing content
72
+ content=content or existing_markdown.content
73
+
74
+ # Create new file structure with provided content
75
+ markdown = entity_model_to_markdown(entity, content=content)
76
+
77
+ # Write back atomically
78
+ checksum = await self.markdown_processor.write_file(
79
+ path=path, markdown=markdown, expected_checksum=expected_checksum
80
+ )
81
+
82
+ return path, checksum
83
+
84
+ except Exception as e:
85
+ logger.exception(f"Failed to write entity file: {e}")
86
+ raise FileOperationError(f"Failed to write entity file: {e}")
87
+
88
+ async def read_entity_content(self, entity: EntityModel) -> str:
89
+ """Get entity's content without frontmatter or structured sections (used to index for search)
90
+
91
+ Args:
92
+ entity: Entity to read content for
93
+
94
+ Returns:
95
+ Raw content without frontmatter, observations, or relations
96
+
97
+ Raises:
98
+ FileOperationError: If entity file doesn't exist
99
+ """
100
+ logger.debug(f"Reading entity with permalink: {entity.permalink}")
101
+
102
+ try:
103
+ file_path = self.get_entity_path(entity)
104
+ markdown = await self.markdown_processor.read_file(file_path)
105
+ return markdown.content or ""
106
+
107
+ except Exception as e:
108
+ logger.error(f"Failed to read entity content: {e}")
109
+ raise FileOperationError(f"Failed to read entity content: {e}")
110
+
111
+ async def delete_entity_file(self, entity: EntityModel) -> None:
112
+ """Delete entity file from filesystem."""
113
+ try:
114
+ path = self.get_entity_path(entity)
115
+ await self.delete_file(path)
116
+ except Exception as e:
117
+ logger.error(f"Failed to delete entity file: {e}")
118
+ raise FileOperationError(f"Failed to delete entity file: {e}")
119
+
120
+ async def exists(self, path: Path) -> bool:
121
+ """
122
+ Check if file exists at the provided path. If path is relative, it is assumed to be relative to base_path.
123
+
124
+ Args:
125
+ path: Path to check
126
+
127
+ Returns:
128
+ True if file exists, False otherwise
129
+ """
130
+ try:
131
+ if path.is_absolute():
132
+ return path.exists()
133
+ else:
134
+ return (self.base_path / path).exists()
135
+ except Exception as e:
136
+ logger.error(f"Failed to check file existence {path}: {e}")
137
+ raise FileOperationError(f"Failed to check file existence: {e}")
138
+
139
+ async def write_file(self, path: Path, content: str) -> str:
140
+ """
141
+ Write content to file and return checksum.
142
+
143
+ Args:
144
+ path: Path where to write
145
+ content: Content to write
146
+
147
+ Returns:
148
+ Checksum of written content
149
+
150
+ Raises:
151
+ FileOperationError: If write fails
152
+ """
153
+
154
+ path = path if path.is_absolute() else self.base_path / path
155
+ try:
156
+ # Ensure parent directory exists
157
+ await file_utils.ensure_directory(path.parent)
158
+
159
+ # Write content atomically
160
+ await file_utils.write_file_atomic(path, content)
161
+
162
+ # Compute and return checksum
163
+ checksum = await file_utils.compute_checksum(content)
164
+ logger.debug(f"wrote file: {path}, checksum: {checksum} content: \n{content}")
165
+ return checksum
166
+
167
+ except Exception as e:
168
+ logger.error(f"Failed to write file {path}: {e}")
169
+ raise FileOperationError(f"Failed to write file: {e}")
170
+
171
+ async def read_file(self, path: Path) -> Tuple[str, str]:
172
+ """
173
+ Read file and compute checksum.
174
+
175
+ Args:
176
+ path: Path to read
177
+
178
+ Returns:
179
+ Tuple of (content, checksum)
180
+
181
+ Raises:
182
+ FileOperationError: If read fails
183
+ """
184
+ path = path if path.is_absolute() else self.base_path / path
185
+ try:
186
+ content = path.read_text()
187
+ checksum = await file_utils.compute_checksum(content)
188
+ logger.debug(f"read file: {path}, checksum: {checksum}")
189
+ return content, checksum
190
+
191
+ except Exception as e:
192
+ logger.error(f"Failed to read file {path}: {e}")
193
+ raise FileOperationError(f"Failed to read file: {e}")
194
+
195
+ async def delete_file(self, path: Path) -> None:
196
+ """
197
+ Delete file if it exists.
198
+
199
+ Args:
200
+ path: Path to delete
201
+
202
+ Raises:
203
+ FileOperationError: If deletion fails
204
+ """
205
+ path = path if path.is_absolute() else self.base_path / path
206
+ try:
207
+ path.unlink(missing_ok=True)
208
+ except Exception as e:
209
+ logger.error(f"Failed to delete file {path}: {e}")
210
+ raise FileOperationError(f"Failed to delete file: {e}")
211
+
212
+ def path(self, path_string: str, absolute: bool = False):
213
+ return Path( self.base_path / path_string ) if absolute else Path(path_string).relative_to(self.base_path)
@@ -0,0 +1,126 @@
1
+ """Service for resolving markdown links to permalinks."""
2
+
3
+ from typing import Optional, Tuple, List
4
+
5
+ from loguru import logger
6
+
7
+ from basic_memory.repository.entity_repository import EntityRepository
8
+ from basic_memory.services.search_service import SearchService
9
+ from basic_memory.models import Entity
10
+ from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
11
+
12
+
13
+ class LinkResolver:
14
+ """Service for resolving markdown links to permalinks.
15
+
16
+ Uses a combination of exact matching and search-based resolution:
17
+ 1. Try exact permalink match (fastest)
18
+ 2. Try exact title match
19
+ 3. Fall back to search for fuzzy matching
20
+ 4. Generate new permalink if no match found
21
+ """
22
+
23
+ def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
24
+ """Initialize with repositories."""
25
+ self.entity_repository = entity_repository
26
+ self.search_service = search_service
27
+
28
+ async def resolve_link(self, link_text: str, use_search: bool = True) -> Optional[Entity]:
29
+ """Resolve a markdown link to a permalink."""
30
+ logger.debug(f"Resolving link: {link_text}")
31
+
32
+ # Clean link text and extract any alias
33
+ clean_text, alias = self._normalize_link_text(link_text)
34
+
35
+ # 1. Try exact permalink match first (most efficient)
36
+ entity = await self.entity_repository.get_by_permalink(clean_text)
37
+ if entity:
38
+ logger.debug(f"Found exact permalink match: {entity.permalink}")
39
+ return entity
40
+
41
+ # 2. Try exact title match
42
+ entity = await self.entity_repository.get_by_title(clean_text)
43
+ if entity:
44
+ logger.debug(f"Found title match: {entity.title}")
45
+ return entity
46
+
47
+ if use_search:
48
+
49
+ # 3. Fall back to search for fuzzy matching on title if specified
50
+ results = await self.search_service.search(
51
+ query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]),
52
+ )
53
+
54
+ if results:
55
+ # Look for best match
56
+ best_match = self._select_best_match(clean_text, results)
57
+ logger.debug(f"Selected best match from {len(results)} results: {best_match.permalink}")
58
+ return await self.entity_repository.get_by_permalink(best_match.permalink)
59
+
60
+ # if we couldn't find anything then return None
61
+ return None
62
+
63
+ def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
64
+ """Normalize link text and extract alias if present.
65
+
66
+ Args:
67
+ link_text: Raw link text from markdown
68
+
69
+ Returns:
70
+ Tuple of (normalized_text, alias or None)
71
+ """
72
+ # Strip whitespace
73
+ text = link_text.strip()
74
+
75
+ # Remove enclosing brackets if present
76
+ if text.startswith("[[") and text.endswith("]]"):
77
+ text = text[2:-2]
78
+
79
+ # Handle Obsidian-style aliases (format: [[actual|alias]])
80
+ alias = None
81
+ if "|" in text:
82
+ text, alias = text.split("|", 1)
83
+ text = text.strip()
84
+ alias = alias.strip()
85
+
86
+ return text, alias
87
+
88
+ def _select_best_match(self, search_text: str, results: List[SearchResult]) -> Entity:
89
+ """Select best match from search results.
90
+
91
+ Uses multiple criteria:
92
+ 1. Word matches in title field
93
+ 2. Word matches in path
94
+ 3. Overall search score
95
+ """
96
+ if not results:
97
+ raise ValueError("Cannot select from empty results")
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
+
108
+ # Parse path components
109
+ path_parts = result.permalink.lower().split("/")
110
+ last_part = path_parts[-1] if path_parts else ""
111
+
112
+ # Title word match boosts
113
+ term_matches = [term for term in terms if term in last_part]
114
+ if term_matches:
115
+ score *= 0.5 # Boost for each matching term
116
+
117
+ # Exact title match is best
118
+ if last_part == search_text.lower():
119
+ score *= 0.2
120
+
121
+ scored_results.append((score, result))
122
+
123
+ # Sort by score (lowest first) and return best
124
+ scored_results.sort(key=lambda x: x[0], reverse=True)
125
+
126
+ return scored_results[0][1]
@@ -0,0 +1,218 @@
1
+ """Service for search operations."""
2
+
3
+ from typing import List, Optional, Set
4
+
5
+ from fastapi import BackgroundTasks
6
+ from loguru import logger
7
+
8
+ from basic_memory.models import Entity
9
+ from basic_memory.repository import EntityRepository
10
+ from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
11
+ from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
12
+ from basic_memory.services import FileService
13
+ from basic_memory.services.exceptions import FileOperationError
14
+
15
+
16
+ class SearchService:
17
+ """Service for search operations.
18
+
19
+ Supports three primary search modes:
20
+ 1. Exact permalink lookup
21
+ 2. Pattern matching with * (e.g., 'specs/*')
22
+ 3. Full-text search across title/content
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ search_repository: SearchRepository,
28
+ entity_repository: EntityRepository,
29
+ file_service: FileService,
30
+ ):
31
+ self.repository = search_repository
32
+ self.entity_repository = entity_repository
33
+ self.file_service = file_service
34
+
35
+ async def init_search_index(self):
36
+ """Create FTS5 virtual table if it doesn't exist."""
37
+ await self.repository.init_search_index()
38
+
39
+ async def reindex_all(self, background_tasks: Optional[BackgroundTasks] = None) -> None:
40
+ """Reindex all content from database."""
41
+ logger.info("Starting full reindex")
42
+
43
+ # Clear and recreate search index
44
+ await self.init_search_index()
45
+
46
+ # Reindex all entities
47
+ logger.debug("Indexing entities")
48
+ entities = await self.entity_repository.find_all()
49
+ for entity in entities:
50
+ await self.index_entity(entity, background_tasks)
51
+
52
+ logger.info("Reindex complete")
53
+
54
+ async def search(
55
+ self, query: SearchQuery, context: Optional[List[str]] = None
56
+ ) -> List[SearchResult]:
57
+ """Search across all indexed content.
58
+
59
+ Supports three modes:
60
+ 1. Exact permalink: finds direct matches for a specific path
61
+ 2. Pattern match: handles * wildcards in paths
62
+ 3. Text search: full-text search across title/content
63
+ """
64
+ if query.no_criteria():
65
+ logger.debug("no criteria passed to query")
66
+ return []
67
+
68
+ logger.debug(f"Searching with query: {query}")
69
+
70
+ # permalink search
71
+ results = await self.repository.search(
72
+ search_text=query.text,
73
+ permalink=query.permalink,
74
+ permalink_match=query.permalink_match,
75
+ title=query.title,
76
+ types=query.types,
77
+ entity_types=query.entity_types,
78
+ after_date=query.after_date,
79
+ )
80
+
81
+ return results
82
+
83
+ def _generate_variants(self, text: str) -> Set[str]:
84
+ """Generate text variants for better fuzzy matching.
85
+
86
+ Creates variations of the text to improve match chances:
87
+ - Original form
88
+ - Lowercase form
89
+ - Path segments (for permalinks)
90
+ - Common word boundaries
91
+ """
92
+ variants = {text, text.lower()}
93
+
94
+ # Add path segments
95
+ if "/" in text:
96
+ variants.update(p.strip() for p in text.split("/") if p.strip())
97
+
98
+ # Add word boundaries
99
+ variants.update(w.strip() for w in text.lower().split() if w.strip())
100
+
101
+ # Add trigrams for fuzzy matching
102
+ variants.update(text[i : i + 3].lower() for i in range(len(text) - 2))
103
+
104
+ return variants
105
+
106
+ async def index_entity(
107
+ self,
108
+ entity: Entity,
109
+ background_tasks: Optional[BackgroundTasks] = None,
110
+ ) -> None:
111
+ """Index an entity and all its observations and relations.
112
+
113
+ Indexing structure:
114
+ 1. Entities
115
+ - permalink: direct from entity (e.g., "specs/search")
116
+ - file_path: physical file location
117
+
118
+ 2. Observations
119
+ - permalink: entity permalink + /observations/id (e.g., "specs/search/observations/123")
120
+ - file_path: parent entity's file (where observation is defined)
121
+
122
+ 3. Relations (only index outgoing relations defined in this file)
123
+ - permalink: from_entity/relation_type/to_entity (e.g., "specs/search/implements/features/search-ui")
124
+ - file_path: source entity's file (where relation is defined)
125
+
126
+ Each type gets its own row in the search index with appropriate metadata.
127
+ """
128
+ if background_tasks:
129
+ background_tasks.add_task(self.index_entity_data, entity)
130
+ else:
131
+ await self.index_entity_data(entity)
132
+
133
+ async def index_entity_data(
134
+ self,
135
+ entity: Entity,
136
+ ) -> None:
137
+ """Actually perform the indexing."""
138
+
139
+ content_parts = []
140
+ title_variants = self._generate_variants(entity.title)
141
+ content_parts.extend(title_variants)
142
+
143
+ # TODO should we do something to content on indexing?
144
+ content = await self.file_service.read_entity_content(entity)
145
+ if content:
146
+ content_parts.append(content)
147
+
148
+ content_parts.extend(self._generate_variants(entity.permalink))
149
+ content_parts.extend(self._generate_variants(entity.file_path))
150
+
151
+ entity_content = "\n".join(p for p in content_parts if p and p.strip())
152
+
153
+ # Index entity
154
+ await self.repository.index_item(
155
+ SearchIndexRow(
156
+ id=entity.id,
157
+ type=SearchItemType.ENTITY.value,
158
+ title=entity.title,
159
+ content=entity_content,
160
+ permalink=entity.permalink,
161
+ file_path=entity.file_path,
162
+ metadata={
163
+ "entity_type": entity.entity_type,
164
+ },
165
+ created_at=entity.created_at.isoformat(),
166
+ updated_at=entity.updated_at.isoformat(),
167
+ )
168
+ )
169
+
170
+ # Index each observation with permalink
171
+ for obs in entity.observations:
172
+ # Index with parent entity's file path since that's where it's defined
173
+ await self.repository.index_item(
174
+ SearchIndexRow(
175
+ id=obs.id,
176
+ type=SearchItemType.OBSERVATION.value,
177
+ title=f"{obs.category}: {obs.content[:50]}...",
178
+ content=obs.content,
179
+ permalink=obs.permalink,
180
+ file_path=entity.file_path,
181
+ category=obs.category,
182
+ entity_id=entity.id,
183
+ metadata={
184
+ "tags": obs.tags,
185
+ },
186
+ created_at=entity.created_at.isoformat(),
187
+ updated_at=entity.updated_at.isoformat(),
188
+ )
189
+ )
190
+
191
+ # Only index outgoing relations (ones defined in this file)
192
+ for rel in entity.outgoing_relations:
193
+ # Create descriptive title showing the relationship
194
+ relation_title = (
195
+ f"{rel.from_entity.title} → {rel.to_entity.title}"
196
+ if rel.to_entity
197
+ else f"{rel.from_entity.title}"
198
+ )
199
+
200
+ await self.repository.index_item(
201
+ SearchIndexRow(
202
+ id=rel.id,
203
+ title=relation_title,
204
+ content=rel.context or "",
205
+ permalink=rel.permalink,
206
+ file_path=entity.file_path,
207
+ type=SearchItemType.RELATION.value,
208
+ from_id=rel.from_id,
209
+ to_id=rel.to_id,
210
+ relation_type=rel.relation_type,
211
+ created_at=entity.created_at.isoformat(),
212
+ updated_at=entity.updated_at.isoformat(),
213
+ )
214
+ )
215
+
216
+ async def delete_by_permalink(self, path_id: str):
217
+ """Delete an item from the search index."""
218
+ await self.repository.delete_by_permalink(path_id)
@@ -0,0 +1,36 @@
1
+ """Base service class."""
2
+
3
+ from datetime import datetime
4
+ from typing import TypeVar, Generic, List, Sequence
5
+
6
+ from basic_memory.models import Base
7
+ from basic_memory.repository.repository import Repository
8
+
9
+ T = TypeVar("T", bound=Base)
10
+ R = TypeVar("R", bound=Repository)
11
+
12
+ class BaseService(Generic[T]):
13
+ """Base service that takes a repository."""
14
+
15
+ def __init__(self, repository: R):
16
+ """Initialize service with repository."""
17
+ self.repository = repository
18
+
19
+ async def add(self, model: T) -> T:
20
+ """Add model to repository."""
21
+ return await self.repository.add(model)
22
+
23
+ async def add_all(self, models: List[T]) -> Sequence[T]:
24
+ """Add a List of models to repository."""
25
+ return await self.repository.add_all(models)
26
+
27
+ async def get_modified_since(self, since: datetime) -> Sequence[T]:
28
+ """Get all items modified since the given timestamp.
29
+
30
+ Args:
31
+ since: Datetime to search from
32
+
33
+ Returns:
34
+ Sequence of items modified since the timestamp
35
+ """
36
+ return await self.repository.find_modified_since(since)
@@ -0,0 +1,5 @@
1
+ from .file_change_scanner import FileChangeScanner
2
+ from .sync_service import SyncService
3
+
4
+ __all__ = ["SyncService", "FileChangeScanner"]
5
+