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.
- basic_memory/__init__.py +3 -0
- basic_memory/api/__init__.py +4 -0
- basic_memory/api/app.py +42 -0
- basic_memory/api/routers/__init__.py +8 -0
- basic_memory/api/routers/knowledge_router.py +168 -0
- basic_memory/api/routers/memory_router.py +123 -0
- basic_memory/api/routers/resource_router.py +34 -0
- basic_memory/api/routers/search_router.py +34 -0
- basic_memory/cli/__init__.py +1 -0
- basic_memory/cli/app.py +4 -0
- basic_memory/cli/commands/__init__.py +9 -0
- basic_memory/cli/commands/init.py +38 -0
- basic_memory/cli/commands/status.py +152 -0
- basic_memory/cli/commands/sync.py +254 -0
- basic_memory/cli/main.py +48 -0
- basic_memory/config.py +53 -0
- basic_memory/db.py +135 -0
- basic_memory/deps.py +182 -0
- basic_memory/file_utils.py +248 -0
- basic_memory/markdown/__init__.py +19 -0
- basic_memory/markdown/entity_parser.py +137 -0
- basic_memory/markdown/markdown_processor.py +153 -0
- basic_memory/markdown/plugins.py +236 -0
- basic_memory/markdown/schemas.py +73 -0
- basic_memory/markdown/utils.py +144 -0
- basic_memory/mcp/__init__.py +1 -0
- basic_memory/mcp/async_client.py +10 -0
- basic_memory/mcp/main.py +21 -0
- basic_memory/mcp/server.py +39 -0
- basic_memory/mcp/tools/__init__.py +34 -0
- basic_memory/mcp/tools/ai_edit.py +84 -0
- basic_memory/mcp/tools/knowledge.py +56 -0
- basic_memory/mcp/tools/memory.py +142 -0
- basic_memory/mcp/tools/notes.py +122 -0
- basic_memory/mcp/tools/search.py +28 -0
- basic_memory/mcp/tools/utils.py +154 -0
- basic_memory/models/__init__.py +12 -0
- basic_memory/models/base.py +9 -0
- basic_memory/models/knowledge.py +204 -0
- basic_memory/models/search.py +34 -0
- basic_memory/repository/__init__.py +7 -0
- basic_memory/repository/entity_repository.py +156 -0
- basic_memory/repository/observation_repository.py +40 -0
- basic_memory/repository/relation_repository.py +78 -0
- basic_memory/repository/repository.py +303 -0
- basic_memory/repository/search_repository.py +259 -0
- basic_memory/schemas/__init__.py +73 -0
- basic_memory/schemas/base.py +216 -0
- basic_memory/schemas/delete.py +38 -0
- basic_memory/schemas/discovery.py +25 -0
- basic_memory/schemas/memory.py +111 -0
- basic_memory/schemas/request.py +77 -0
- basic_memory/schemas/response.py +220 -0
- basic_memory/schemas/search.py +117 -0
- basic_memory/services/__init__.py +11 -0
- basic_memory/services/context_service.py +274 -0
- basic_memory/services/entity_service.py +281 -0
- basic_memory/services/exceptions.py +15 -0
- basic_memory/services/file_service.py +213 -0
- basic_memory/services/link_resolver.py +126 -0
- basic_memory/services/search_service.py +218 -0
- basic_memory/services/service.py +36 -0
- basic_memory/sync/__init__.py +5 -0
- basic_memory/sync/file_change_scanner.py +162 -0
- basic_memory/sync/sync_service.py +140 -0
- basic_memory/sync/utils.py +66 -0
- basic_memory/sync/watch_service.py +197 -0
- basic_memory/utils.py +78 -0
- basic_memory-0.0.0.dist-info/METADATA +71 -0
- basic_memory-0.0.0.dist-info/RECORD +73 -0
- basic_memory-0.0.0.dist-info/WHEEL +4 -0
- basic_memory-0.0.0.dist-info/entry_points.txt +2 -0
- basic_memory-0.0.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Service for detecting changes between filesystem and database."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Sequence
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from basic_memory.file_utils import compute_checksum
|
|
10
|
+
from basic_memory.models import Entity
|
|
11
|
+
from basic_memory.repository.entity_repository import EntityRepository
|
|
12
|
+
from basic_memory.sync.utils import SyncReport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FileState:
|
|
17
|
+
"""State of a file including file path, permalink and checksum info."""
|
|
18
|
+
|
|
19
|
+
file_path: str
|
|
20
|
+
permalink: str
|
|
21
|
+
checksum: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ScanResult:
|
|
26
|
+
"""Result of scanning a directory."""
|
|
27
|
+
|
|
28
|
+
# file_path -> checksum
|
|
29
|
+
files: Dict[str, str] = field(default_factory=dict)
|
|
30
|
+
# file_path -> error message
|
|
31
|
+
errors: Dict[str, str] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FileChangeScanner:
|
|
35
|
+
"""
|
|
36
|
+
Service for detecting changes between filesystem and database.
|
|
37
|
+
The filesystem is treated as the source of truth.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, entity_repository: EntityRepository):
|
|
41
|
+
self.entity_repository = entity_repository
|
|
42
|
+
|
|
43
|
+
async def scan_directory(self, directory: Path) -> ScanResult:
|
|
44
|
+
"""
|
|
45
|
+
Scan directory for markdown files and their checksums.
|
|
46
|
+
Only processes .md files, logs and skips others.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
directory: Directory to scan
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
ScanResult containing found files and any errors
|
|
53
|
+
"""
|
|
54
|
+
logger.debug(f"Scanning directory: {directory}")
|
|
55
|
+
result = ScanResult()
|
|
56
|
+
|
|
57
|
+
if not directory.exists():
|
|
58
|
+
logger.debug(f"Directory does not exist: {directory}")
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
for path in directory.rglob("*"):
|
|
62
|
+
if not path.is_file() or not path.name.endswith(".md"):
|
|
63
|
+
if path.is_file():
|
|
64
|
+
logger.debug(f"Skipping non-markdown file: {path}")
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# Get relative path first - used in error reporting if needed
|
|
69
|
+
rel_path = str(path.relative_to(directory))
|
|
70
|
+
content = path.read_text()
|
|
71
|
+
checksum = await compute_checksum(content)
|
|
72
|
+
|
|
73
|
+
if checksum: # Only store valid checksums
|
|
74
|
+
result.files[rel_path] = checksum
|
|
75
|
+
else:
|
|
76
|
+
result.errors[rel_path] = "Failed to compute checksum"
|
|
77
|
+
|
|
78
|
+
except Exception as e:
|
|
79
|
+
rel_path = str(path.relative_to(directory))
|
|
80
|
+
result.errors[rel_path] = str(e)
|
|
81
|
+
logger.error(f"Failed to read {rel_path}: {e}")
|
|
82
|
+
|
|
83
|
+
logger.debug(f"Found {len(result.files)} markdown files")
|
|
84
|
+
if result.errors:
|
|
85
|
+
logger.warning(f"Encountered {len(result.errors)} errors while scanning")
|
|
86
|
+
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
async def find_changes(
|
|
90
|
+
self, directory: Path, db_file_state: Dict[str, FileState]
|
|
91
|
+
) -> SyncReport:
|
|
92
|
+
"""Find changes between filesystem and database."""
|
|
93
|
+
# Get current files and checksums
|
|
94
|
+
scan_result = await self.scan_directory(directory)
|
|
95
|
+
current_files = scan_result.files
|
|
96
|
+
|
|
97
|
+
# Build report
|
|
98
|
+
report = SyncReport(total=len(current_files))
|
|
99
|
+
|
|
100
|
+
# Track potentially moved files by checksum
|
|
101
|
+
files_by_checksum = {} # checksum -> file_path
|
|
102
|
+
|
|
103
|
+
# First find potential new files and record checksums
|
|
104
|
+
for file_path, checksum in current_files.items():
|
|
105
|
+
logger.debug(f"{file_path} ({checksum[:8]})")
|
|
106
|
+
|
|
107
|
+
if file_path not in db_file_state:
|
|
108
|
+
# Could be new or could be the destination of a move
|
|
109
|
+
report.new.add(file_path)
|
|
110
|
+
files_by_checksum[checksum] = file_path
|
|
111
|
+
elif checksum != db_file_state[file_path].checksum:
|
|
112
|
+
report.modified.add(file_path)
|
|
113
|
+
|
|
114
|
+
report.checksums[file_path] = checksum
|
|
115
|
+
|
|
116
|
+
# Now detect moves and deletions
|
|
117
|
+
for db_file_path, db_state in db_file_state.items():
|
|
118
|
+
if db_file_path not in current_files:
|
|
119
|
+
if db_state.checksum in files_by_checksum:
|
|
120
|
+
# Found a move - file exists at new path with same checksum
|
|
121
|
+
new_path = files_by_checksum[db_state.checksum]
|
|
122
|
+
report.moves[db_file_path] = new_path
|
|
123
|
+
# Remove from new files since it's a move
|
|
124
|
+
report.new.remove(new_path)
|
|
125
|
+
else:
|
|
126
|
+
# Actually deleted
|
|
127
|
+
report.deleted.add(db_file_path)
|
|
128
|
+
|
|
129
|
+
# Log summary
|
|
130
|
+
logger.debug(f"Total files: {report.total}")
|
|
131
|
+
logger.debug(f"Changes found: {report.total_changes}")
|
|
132
|
+
logger.debug(f" New: {len(report.new)}")
|
|
133
|
+
logger.debug(f" Modified: {len(report.modified)}")
|
|
134
|
+
logger.debug(f" Moved: {len(report.moves)}")
|
|
135
|
+
logger.debug(f" Deleted: {len(report.deleted)}")
|
|
136
|
+
|
|
137
|
+
if scan_result.errors:
|
|
138
|
+
logger.warning("Files skipped due to errors:")
|
|
139
|
+
for file_path, error in scan_result.errors.items():
|
|
140
|
+
logger.warning(f" {file_path}: {error}")
|
|
141
|
+
|
|
142
|
+
return report
|
|
143
|
+
|
|
144
|
+
async def get_db_file_state(self, db_records: Sequence[Entity]) -> Dict[str, FileState]:
|
|
145
|
+
"""Get file_path and checksums from database.
|
|
146
|
+
Args:
|
|
147
|
+
db_records: database records
|
|
148
|
+
Returns:
|
|
149
|
+
Dict mapping file paths to FileState
|
|
150
|
+
:param db_records: the data from the db
|
|
151
|
+
"""
|
|
152
|
+
return {
|
|
153
|
+
r.file_path: FileState(
|
|
154
|
+
file_path=r.file_path, permalink=r.permalink, checksum=r.checksum
|
|
155
|
+
)
|
|
156
|
+
for r in db_records
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async def find_knowledge_changes(self, directory: Path) -> SyncReport:
|
|
160
|
+
"""Find changes in knowledge directory."""
|
|
161
|
+
db_file_state = await self.get_db_file_state(await self.entity_repository.find_all())
|
|
162
|
+
return await self.find_changes(directory=directory, db_file_state=db_file_state)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Service for syncing files between filesystem and database."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from basic_memory.markdown import EntityParser, EntityMarkdown
|
|
9
|
+
from basic_memory.repository import EntityRepository, RelationRepository
|
|
10
|
+
from basic_memory.services import EntityService
|
|
11
|
+
from basic_memory.services.search_service import SearchService
|
|
12
|
+
from basic_memory.sync import FileChangeScanner
|
|
13
|
+
from basic_memory.sync.utils import SyncReport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SyncService:
|
|
17
|
+
"""Syncs documents and knowledge files with database.
|
|
18
|
+
|
|
19
|
+
Implements two-pass sync strategy for knowledge files to handle relations:
|
|
20
|
+
1. First pass creates/updates entities without relations
|
|
21
|
+
2. Second pass processes relations after all entities exist
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
scanner: FileChangeScanner,
|
|
27
|
+
entity_service: EntityService,
|
|
28
|
+
entity_parser: EntityParser,
|
|
29
|
+
entity_repository: EntityRepository,
|
|
30
|
+
relation_repository: RelationRepository,
|
|
31
|
+
search_service: SearchService,
|
|
32
|
+
):
|
|
33
|
+
self.scanner = scanner
|
|
34
|
+
self.entity_service = entity_service
|
|
35
|
+
self.entity_parser = entity_parser
|
|
36
|
+
self.entity_repository = entity_repository
|
|
37
|
+
self.relation_repository = relation_repository
|
|
38
|
+
self.search_service = search_service
|
|
39
|
+
|
|
40
|
+
async def handle_entity_deletion(self, file_path: str):
|
|
41
|
+
"""Handle complete entity deletion including search index cleanup."""
|
|
42
|
+
# First get entity to get permalink before deletion
|
|
43
|
+
entity = await self.entity_repository.get_by_file_path(file_path)
|
|
44
|
+
if entity:
|
|
45
|
+
logger.debug(f"Deleting entity and cleaning up search index: {file_path}")
|
|
46
|
+
|
|
47
|
+
# Delete from db (this cascades to observations/relations)
|
|
48
|
+
await self.entity_service.delete_entity_by_file_path(file_path)
|
|
49
|
+
|
|
50
|
+
# Clean up search index
|
|
51
|
+
permalinks = (
|
|
52
|
+
[entity.permalink]
|
|
53
|
+
+ [o.permalink for o in entity.observations]
|
|
54
|
+
+ [r.permalink for r in entity.relations]
|
|
55
|
+
)
|
|
56
|
+
logger.debug(f"Deleting from search index: {permalinks}")
|
|
57
|
+
for permalink in permalinks:
|
|
58
|
+
await self.search_service.delete_by_permalink(permalink)
|
|
59
|
+
|
|
60
|
+
else:
|
|
61
|
+
logger.debug(f"No entity found to delete: {file_path}")
|
|
62
|
+
|
|
63
|
+
async def sync(self, directory: Path) -> SyncReport:
|
|
64
|
+
"""Sync knowledge files with database."""
|
|
65
|
+
changes = await self.scanner.find_knowledge_changes(directory)
|
|
66
|
+
logger.info(f"Found {changes.total_changes} knowledge changes")
|
|
67
|
+
|
|
68
|
+
# Handle moves first
|
|
69
|
+
for old_path, new_path in changes.moves.items():
|
|
70
|
+
logger.debug(f"Moving entity: {old_path} -> {new_path}")
|
|
71
|
+
entity = await self.entity_repository.get_by_file_path(old_path)
|
|
72
|
+
if entity:
|
|
73
|
+
# Update file_path but keep the same permalink for link stability
|
|
74
|
+
updated = await self.entity_repository.update(
|
|
75
|
+
entity.id, {"file_path": new_path, "checksum": changes.checksums[new_path]}
|
|
76
|
+
)
|
|
77
|
+
# update search index
|
|
78
|
+
await self.search_service.index_entity(updated)
|
|
79
|
+
|
|
80
|
+
# Handle deletions next
|
|
81
|
+
# remove rows from db for files no longer present
|
|
82
|
+
for file_path in changes.deleted:
|
|
83
|
+
await self.handle_entity_deletion(file_path)
|
|
84
|
+
|
|
85
|
+
# Parse files that need updating
|
|
86
|
+
parsed_entities: Dict[str, EntityMarkdown] = {}
|
|
87
|
+
|
|
88
|
+
for file_path in [*changes.new, *changes.modified]:
|
|
89
|
+
entity_markdown = await self.entity_parser.parse_file(directory / file_path)
|
|
90
|
+
parsed_entities[file_path] = entity_markdown
|
|
91
|
+
|
|
92
|
+
# First pass: Create/update entities
|
|
93
|
+
# entities will have a null checksum to indicate they are not complete
|
|
94
|
+
for file_path, entity_markdown in parsed_entities.items():
|
|
95
|
+
# if the file is new, create an entity
|
|
96
|
+
if file_path in changes.new:
|
|
97
|
+
logger.debug(f"Creating new entity_markdown: {file_path}")
|
|
98
|
+
await self.entity_service.create_entity_from_markdown(
|
|
99
|
+
file_path, entity_markdown
|
|
100
|
+
)
|
|
101
|
+
# otherwise we need to update the entity and observations
|
|
102
|
+
else:
|
|
103
|
+
logger.debug(f"Updating entity_markdown: {file_path}")
|
|
104
|
+
await self.entity_service.update_entity_and_observations(
|
|
105
|
+
file_path, entity_markdown
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Second pass
|
|
109
|
+
for file_path, entity_markdown in parsed_entities.items():
|
|
110
|
+
logger.debug(f"Updating relations for: {file_path}")
|
|
111
|
+
|
|
112
|
+
# Process relations
|
|
113
|
+
checksum = changes.checksums[file_path]
|
|
114
|
+
entity = await self.entity_service.update_entity_relations(
|
|
115
|
+
file_path, entity_markdown
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# add to search index
|
|
119
|
+
await self.search_service.index_entity(entity)
|
|
120
|
+
|
|
121
|
+
# Set final checksum to mark sync complete
|
|
122
|
+
await self.entity_repository.update(entity.id, {"checksum": checksum})
|
|
123
|
+
|
|
124
|
+
# Third pass: Try to resolve any forward references
|
|
125
|
+
logger.debug("Attempting to resolve forward references")
|
|
126
|
+
for relation in await self.relation_repository.find_unresolved_relations():
|
|
127
|
+
target_entity = await self.entity_service.link_resolver.resolve_link(relation.to_name)
|
|
128
|
+
# check we found a link that is not the source
|
|
129
|
+
if target_entity and target_entity.id != relation.from_id:
|
|
130
|
+
logger.debug(f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}")
|
|
131
|
+
await self.relation_repository.update(relation.id, {
|
|
132
|
+
"to_id": target_entity.id,
|
|
133
|
+
"to_name": target_entity.title # Update to actual title
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
# update search index
|
|
137
|
+
await self.search_service.index_entity(target_entity)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
return changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Types and utilities for file sync."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Set, Dict, Optional
|
|
5
|
+
from watchfiles import Change
|
|
6
|
+
from basic_memory.services.file_service import FileService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class FileChange:
|
|
11
|
+
"""A change to a file detected by the watch service.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
change_type: Type of change (added, modified, deleted)
|
|
15
|
+
path: Path to the file
|
|
16
|
+
checksum: File checksum (None for deleted files)
|
|
17
|
+
"""
|
|
18
|
+
change_type: Change
|
|
19
|
+
path: str
|
|
20
|
+
checksum: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
async def from_path(cls, path: str, change_type: Change, file_service: FileService) -> "FileChange":
|
|
24
|
+
"""Create FileChange from a path, computing checksum if file exists.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
path: Path to the file
|
|
28
|
+
change_type: Type of change detected
|
|
29
|
+
file_service: Service to read file and compute checksum
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
FileChange with computed checksum for non-deleted files
|
|
33
|
+
"""
|
|
34
|
+
file_path = file_service.path(path)
|
|
35
|
+
content, checksum = await file_service.read_file(file_path) if change_type != Change.deleted else (None, None)
|
|
36
|
+
return cls(path=file_path, change_type=change_type, checksum=checksum)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SyncReport:
|
|
41
|
+
"""Report of file changes found compared to database state.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
total: Total number of files in directory being synced
|
|
45
|
+
new: Files that exist on disk but not in database
|
|
46
|
+
modified: Files that exist in both but have different checksums
|
|
47
|
+
deleted: Files that exist in database but not on disk
|
|
48
|
+
moves: Files that have been moved from one location to another
|
|
49
|
+
checksums: Current checksums for files on disk
|
|
50
|
+
"""
|
|
51
|
+
total: int = 0
|
|
52
|
+
new: Set[str] = field(default_factory=set)
|
|
53
|
+
modified: Set[str] = field(default_factory=set)
|
|
54
|
+
deleted: Set[str] = field(default_factory=set)
|
|
55
|
+
moves: Dict[str, str] = field(default_factory=dict) # old_path -> new_path
|
|
56
|
+
checksums: Dict[str, str] = field(default_factory=dict) # path -> checksum
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def total_changes(self) -> int:
|
|
60
|
+
"""Total number of changes."""
|
|
61
|
+
return len(self.new) + len(self.modified) + len(self.deleted) + len(self.moves)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def syned_files(self) -> int:
|
|
65
|
+
"""Total number of files synced."""
|
|
66
|
+
return len(self.new) + len(self.modified) + len(self.moves)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Watch service for Basic Memory."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from rich import box
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.live import Live
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from watchfiles import awatch, Change
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from basic_memory.config import ProjectConfig
|
|
19
|
+
from basic_memory.sync.sync_service import SyncService
|
|
20
|
+
from basic_memory.services.file_service import FileService
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WatchEvent(BaseModel):
|
|
24
|
+
timestamp: datetime
|
|
25
|
+
path: str
|
|
26
|
+
action: str # new, delete, etc
|
|
27
|
+
status: str # success, error
|
|
28
|
+
checksum: Optional[str]
|
|
29
|
+
error: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WatchServiceState(BaseModel):
|
|
33
|
+
# Service status
|
|
34
|
+
running: bool = False
|
|
35
|
+
start_time: datetime = dataclasses.field(default_factory=datetime.now)
|
|
36
|
+
pid: int = dataclasses.field(default_factory=os.getpid)
|
|
37
|
+
|
|
38
|
+
# Stats
|
|
39
|
+
error_count: int = 0
|
|
40
|
+
last_error: Optional[datetime] = None
|
|
41
|
+
last_scan: Optional[datetime] = None
|
|
42
|
+
|
|
43
|
+
# File counts
|
|
44
|
+
synced_files: int = 0
|
|
45
|
+
|
|
46
|
+
# Recent activity
|
|
47
|
+
recent_events: List[WatchEvent] = dataclasses.field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
def add_event(
|
|
50
|
+
self,
|
|
51
|
+
path: str,
|
|
52
|
+
action: str,
|
|
53
|
+
status: str,
|
|
54
|
+
checksum: Optional[str] = None,
|
|
55
|
+
error: Optional[str] = None,
|
|
56
|
+
) -> WatchEvent:
|
|
57
|
+
event = WatchEvent(
|
|
58
|
+
timestamp=datetime.now(),
|
|
59
|
+
path=path,
|
|
60
|
+
action=action,
|
|
61
|
+
status=status,
|
|
62
|
+
checksum=checksum,
|
|
63
|
+
error=error,
|
|
64
|
+
)
|
|
65
|
+
self.recent_events.insert(0, event)
|
|
66
|
+
self.recent_events = self.recent_events[:100] # Keep last 100
|
|
67
|
+
return event
|
|
68
|
+
|
|
69
|
+
def record_error(self, error: str):
|
|
70
|
+
self.error_count += 1
|
|
71
|
+
self.add_event(path="", action="sync", status="error", error=error)
|
|
72
|
+
self.last_error = datetime.now()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class WatchService:
|
|
76
|
+
def __init__(self, sync_service: SyncService, file_service: FileService, config: ProjectConfig):
|
|
77
|
+
self.sync_service = sync_service
|
|
78
|
+
self.file_service = file_service
|
|
79
|
+
self.config = config
|
|
80
|
+
self.state = WatchServiceState()
|
|
81
|
+
self.status_path = config.home / ".basic-memory" / "watch-status.json"
|
|
82
|
+
self.status_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
self.console = Console()
|
|
84
|
+
|
|
85
|
+
def generate_table(self) -> Table:
|
|
86
|
+
"""Generate status display table"""
|
|
87
|
+
table = Table(title="Basic Memory Sync Status")
|
|
88
|
+
|
|
89
|
+
# Add status row
|
|
90
|
+
table.add_column("Status", style="cyan")
|
|
91
|
+
table.add_column("Last Scan", style="cyan")
|
|
92
|
+
table.add_column("Files", style="cyan")
|
|
93
|
+
table.add_column("Errors", style="red")
|
|
94
|
+
|
|
95
|
+
# Add main status row
|
|
96
|
+
table.add_row(
|
|
97
|
+
"✓ Running" if self.state.running else "✗ Stopped",
|
|
98
|
+
self.state.last_scan.strftime("%H:%M:%S") if self.state.last_scan else "-",
|
|
99
|
+
str(self.state.synced_files),
|
|
100
|
+
f"{self.state.error_count} ({self.state.last_error.strftime('%H:%M:%S') if self.state.last_error else 'none'})",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if self.state.recent_events:
|
|
104
|
+
# Add recent events
|
|
105
|
+
table.add_section()
|
|
106
|
+
table.add_row("Recent Events", "", "", "")
|
|
107
|
+
|
|
108
|
+
for event in self.state.recent_events[:5]: # Show last 5 events
|
|
109
|
+
color = {
|
|
110
|
+
"new": "green",
|
|
111
|
+
"modified": "yellow",
|
|
112
|
+
"moved": "blue",
|
|
113
|
+
"deleted": "red",
|
|
114
|
+
"error": "red",
|
|
115
|
+
}.get(event.action, "white")
|
|
116
|
+
|
|
117
|
+
icon = {
|
|
118
|
+
"new": "✚",
|
|
119
|
+
"modified": "✎",
|
|
120
|
+
"moved": "→",
|
|
121
|
+
"deleted": "✖",
|
|
122
|
+
"error": "!",
|
|
123
|
+
}.get(event.action, "*")
|
|
124
|
+
|
|
125
|
+
table.add_row(
|
|
126
|
+
f"[{color}]{icon} {event.action}[/{color}]",
|
|
127
|
+
event.timestamp.strftime("%H:%M:%S"),
|
|
128
|
+
f"[{color}]{event.path}[/{color}]",
|
|
129
|
+
f"[dim]{event.checksum[:8] if event.checksum else ''}[/dim]",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return table
|
|
133
|
+
|
|
134
|
+
async def run(self):
|
|
135
|
+
"""Watch for file changes and sync them"""
|
|
136
|
+
self.state.running = True
|
|
137
|
+
self.state.start_time = datetime.now()
|
|
138
|
+
await self.write_status()
|
|
139
|
+
|
|
140
|
+
with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live:
|
|
141
|
+
try:
|
|
142
|
+
async for changes in awatch(
|
|
143
|
+
self.config.home,
|
|
144
|
+
watch_filter=self.filter_changes,
|
|
145
|
+
debounce=self.config.sync_delay,
|
|
146
|
+
recursive=True,
|
|
147
|
+
):
|
|
148
|
+
# Process changes
|
|
149
|
+
await self.handle_changes(self.config.home)
|
|
150
|
+
# Update display
|
|
151
|
+
live.update(self.generate_table())
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
self.state.record_error(str(e))
|
|
155
|
+
await self.write_status()
|
|
156
|
+
raise
|
|
157
|
+
finally:
|
|
158
|
+
self.state.running = False
|
|
159
|
+
await self.write_status()
|
|
160
|
+
|
|
161
|
+
async def write_status(self):
|
|
162
|
+
"""Write current state to status file"""
|
|
163
|
+
self.status_path.write_text(WatchServiceState.model_dump_json(self.state, indent=2))
|
|
164
|
+
|
|
165
|
+
def filter_changes(self, change: Change, path: str) -> bool:
|
|
166
|
+
"""Filter to only watch markdown files"""
|
|
167
|
+
return path.endswith(".md") and not Path(path).name.startswith(".")
|
|
168
|
+
|
|
169
|
+
async def handle_changes(self, directory: Path):
|
|
170
|
+
"""Process a batch of file changes"""
|
|
171
|
+
|
|
172
|
+
logger.debug(f"handling change in directory: {directory} ...")
|
|
173
|
+
# Process changes with timeout
|
|
174
|
+
report = await self.sync_service.sync(directory)
|
|
175
|
+
self.state.last_scan = datetime.now()
|
|
176
|
+
self.state.synced_files = report.total
|
|
177
|
+
|
|
178
|
+
# Update stats
|
|
179
|
+
for path in report.new:
|
|
180
|
+
self.state.add_event(
|
|
181
|
+
path=path, action="new", status="success", checksum=report.checksums[path]
|
|
182
|
+
)
|
|
183
|
+
for path in report.modified:
|
|
184
|
+
self.state.add_event(
|
|
185
|
+
path=path, action="modified", status="success", checksum=report.checksums[path]
|
|
186
|
+
)
|
|
187
|
+
for old_path, new_path in report.moves.items():
|
|
188
|
+
self.state.add_event(
|
|
189
|
+
path=f"{old_path} -> {new_path}",
|
|
190
|
+
action="moved",
|
|
191
|
+
status="success",
|
|
192
|
+
checksum=report.checksums[new_path],
|
|
193
|
+
)
|
|
194
|
+
for path in report.deleted:
|
|
195
|
+
self.state.add_event(path=path, action="deleted", status="success")
|
|
196
|
+
|
|
197
|
+
await self.write_status()
|
basic_memory/utils.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Utility functions for basic-memory."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import unicodedata
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from unidecode import unidecode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def sanitize_name(name: str) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Sanitize a name for filesystem use:
|
|
13
|
+
- Convert to lowercase
|
|
14
|
+
- Replace spaces/punctuation with underscores
|
|
15
|
+
- Remove emojis and other special characters
|
|
16
|
+
- Collapse multiple underscores
|
|
17
|
+
- Trim leading/trailing underscores
|
|
18
|
+
"""
|
|
19
|
+
# Normalize unicode to compose characters where possible
|
|
20
|
+
name = unicodedata.normalize("NFKD", name)
|
|
21
|
+
# Remove emojis and other special characters, keep only letters, numbers, spaces
|
|
22
|
+
name = "".join(c for c in name if c.isalnum() or c.isspace())
|
|
23
|
+
# Replace spaces with underscores
|
|
24
|
+
name = name.replace(" ", "_")
|
|
25
|
+
# Remove newline
|
|
26
|
+
name = name.replace("\n", "")
|
|
27
|
+
# Convert to lowercase
|
|
28
|
+
name = name.lower()
|
|
29
|
+
# Collapse multiple underscores and trim
|
|
30
|
+
name = re.sub(r"_+", "_", name).strip("_")
|
|
31
|
+
|
|
32
|
+
return name
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_permalink(file_path: Path | str) -> str:
|
|
36
|
+
"""Generate a stable permalink from a file path.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
file_path: Original file path
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Normalized permalink that matches validation rules. Converts spaces and underscores
|
|
43
|
+
to hyphens for consistency.
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
>>> generate_permalink("docs/My Feature.md")
|
|
47
|
+
'docs/my-feature'
|
|
48
|
+
>>> generate_permalink("specs/API (v2).md")
|
|
49
|
+
'specs/api-v2'
|
|
50
|
+
>>> generate_permalink("design/unified_model_refactor.md")
|
|
51
|
+
'design/unified-model-refactor'
|
|
52
|
+
"""
|
|
53
|
+
# Remove extension
|
|
54
|
+
base = os.path.splitext(file_path)[0]
|
|
55
|
+
|
|
56
|
+
# Transliterate unicode to ascii
|
|
57
|
+
ascii_text = unidecode(base)
|
|
58
|
+
|
|
59
|
+
# Insert dash between camelCase
|
|
60
|
+
ascii_text = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", ascii_text)
|
|
61
|
+
|
|
62
|
+
# Convert to lowercase
|
|
63
|
+
lower_text = ascii_text.lower()
|
|
64
|
+
|
|
65
|
+
# replace underscores with hyphens
|
|
66
|
+
text_with_hyphens = lower_text.replace('_', '-')
|
|
67
|
+
|
|
68
|
+
# Replace remaining invalid chars with hyphens
|
|
69
|
+
clean_text = re.sub(r'[^a-z0-9/\-]', '-', text_with_hyphens)
|
|
70
|
+
|
|
71
|
+
# Collapse multiple hyphens
|
|
72
|
+
clean_text = re.sub(r'-+', '-', clean_text)
|
|
73
|
+
|
|
74
|
+
# Clean each path segment
|
|
75
|
+
segments = clean_text.split('/')
|
|
76
|
+
clean_segments = [s.strip('-') for s in segments]
|
|
77
|
+
|
|
78
|
+
return '/'.join(clean_segments)
|