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,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)