basic-memory 0.1.1__py3-none-any.whl → 0.1.2__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/README +1 -0
- basic_memory/alembic/env.py +75 -0
- basic_memory/alembic/migrations.py +29 -0
- basic_memory/alembic/script.py.mako +26 -0
- basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
- basic_memory/api/__init__.py +2 -1
- basic_memory/api/app.py +26 -24
- basic_memory/api/routers/knowledge_router.py +28 -26
- basic_memory/api/routers/memory_router.py +17 -11
- basic_memory/api/routers/search_router.py +6 -12
- basic_memory/cli/__init__.py +1 -1
- basic_memory/cli/app.py +0 -1
- basic_memory/cli/commands/__init__.py +3 -3
- basic_memory/cli/commands/db.py +25 -0
- basic_memory/cli/commands/import_memory_json.py +35 -31
- basic_memory/cli/commands/mcp.py +20 -0
- basic_memory/cli/commands/status.py +10 -6
- basic_memory/cli/commands/sync.py +5 -56
- basic_memory/cli/main.py +5 -38
- basic_memory/config.py +3 -3
- basic_memory/db.py +15 -22
- basic_memory/deps.py +3 -4
- basic_memory/file_utils.py +36 -35
- basic_memory/markdown/entity_parser.py +13 -30
- basic_memory/markdown/markdown_processor.py +7 -7
- basic_memory/markdown/plugins.py +109 -123
- basic_memory/markdown/schemas.py +7 -8
- basic_memory/markdown/utils.py +70 -121
- basic_memory/mcp/__init__.py +1 -1
- basic_memory/mcp/async_client.py +0 -2
- basic_memory/mcp/server.py +3 -27
- basic_memory/mcp/tools/__init__.py +5 -3
- basic_memory/mcp/tools/knowledge.py +2 -2
- basic_memory/mcp/tools/memory.py +8 -4
- basic_memory/mcp/tools/search.py +2 -1
- basic_memory/mcp/tools/utils.py +1 -1
- basic_memory/models/__init__.py +1 -2
- basic_memory/models/base.py +3 -3
- basic_memory/models/knowledge.py +23 -60
- basic_memory/models/search.py +1 -1
- basic_memory/repository/__init__.py +5 -3
- basic_memory/repository/entity_repository.py +34 -98
- basic_memory/repository/relation_repository.py +0 -7
- basic_memory/repository/repository.py +2 -39
- basic_memory/repository/search_repository.py +20 -25
- basic_memory/schemas/__init__.py +4 -4
- basic_memory/schemas/base.py +21 -62
- basic_memory/schemas/delete.py +2 -3
- basic_memory/schemas/discovery.py +4 -1
- basic_memory/schemas/memory.py +12 -13
- basic_memory/schemas/request.py +4 -23
- basic_memory/schemas/response.py +10 -9
- basic_memory/schemas/search.py +4 -7
- basic_memory/services/__init__.py +2 -7
- basic_memory/services/context_service.py +116 -110
- basic_memory/services/entity_service.py +25 -62
- basic_memory/services/exceptions.py +1 -0
- basic_memory/services/file_service.py +73 -109
- basic_memory/services/link_resolver.py +9 -9
- basic_memory/services/search_service.py +22 -15
- basic_memory/services/service.py +3 -24
- basic_memory/sync/__init__.py +2 -2
- basic_memory/sync/file_change_scanner.py +3 -7
- basic_memory/sync/sync_service.py +35 -40
- basic_memory/sync/utils.py +6 -38
- basic_memory/sync/watch_service.py +26 -5
- basic_memory/utils.py +42 -33
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/METADATA +2 -7
- basic_memory-0.1.2.dist-info/RECORD +78 -0
- basic_memory/mcp/main.py +0 -21
- basic_memory/mcp/tools/ai_edit.py +0 -84
- basic_memory/services/database_service.py +0 -159
- basic_memory-0.1.1.dist-info/RECORD +0 -74
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/WHEEL +0 -0
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/licenses/LICENSE +0 -0
basic_memory/services/service.py
CHANGED
|
@@ -1,36 +1,15 @@
|
|
|
1
1
|
"""Base service class."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from typing import TypeVar, Generic, List, Sequence
|
|
3
|
+
from typing import TypeVar, Generic
|
|
5
4
|
|
|
6
5
|
from basic_memory.models import Base
|
|
7
|
-
from basic_memory.repository.repository import Repository
|
|
8
6
|
|
|
9
7
|
T = TypeVar("T", bound=Base)
|
|
10
|
-
|
|
8
|
+
|
|
11
9
|
|
|
12
10
|
class BaseService(Generic[T]):
|
|
13
11
|
"""Base service that takes a repository."""
|
|
14
12
|
|
|
15
|
-
def __init__(self, repository
|
|
13
|
+
def __init__(self, repository):
|
|
16
14
|
"""Initialize service with repository."""
|
|
17
15
|
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)
|
basic_memory/sync/__init__.py
CHANGED
|
@@ -69,11 +69,7 @@ class FileChangeScanner:
|
|
|
69
69
|
rel_path = str(path.relative_to(directory))
|
|
70
70
|
content = path.read_text()
|
|
71
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"
|
|
72
|
+
result.files[rel_path] = checksum
|
|
77
73
|
|
|
78
74
|
except Exception as e:
|
|
79
75
|
rel_path = str(path.relative_to(directory))
|
|
@@ -134,7 +130,7 @@ class FileChangeScanner:
|
|
|
134
130
|
logger.debug(f" Moved: {len(report.moves)}")
|
|
135
131
|
logger.debug(f" Deleted: {len(report.deleted)}")
|
|
136
132
|
|
|
137
|
-
if scan_result.errors:
|
|
133
|
+
if scan_result.errors: # pragma: no cover
|
|
138
134
|
logger.warning("Files skipped due to errors:")
|
|
139
135
|
for file_path, error in scan_result.errors.items():
|
|
140
136
|
logger.warning(f" {file_path}: {error}")
|
|
@@ -151,7 +147,7 @@ class FileChangeScanner:
|
|
|
151
147
|
"""
|
|
152
148
|
return {
|
|
153
149
|
r.file_path: FileState(
|
|
154
|
-
file_path=r.file_path, permalink=r.permalink, checksum=r.checksum
|
|
150
|
+
file_path=r.file_path, permalink=r.permalink, checksum=r.checksum or ""
|
|
155
151
|
)
|
|
156
152
|
for r in db_records
|
|
157
153
|
}
|
|
@@ -59,9 +59,6 @@ class SyncService:
|
|
|
59
59
|
for permalink in permalinks:
|
|
60
60
|
await self.search_service.delete_by_permalink(permalink)
|
|
61
61
|
|
|
62
|
-
else:
|
|
63
|
-
logger.debug(f"No entity found to delete: {file_path}")
|
|
64
|
-
|
|
65
62
|
async def sync(self, directory: Path) -> SyncReport:
|
|
66
63
|
"""Sync knowledge files with database."""
|
|
67
64
|
changes = await self.scanner.find_knowledge_changes(directory)
|
|
@@ -77,69 +74,63 @@ class SyncService:
|
|
|
77
74
|
entity.id, {"file_path": new_path, "checksum": changes.checksums[new_path]}
|
|
78
75
|
)
|
|
79
76
|
# update search index
|
|
80
|
-
|
|
77
|
+
if updated:
|
|
78
|
+
await self.search_service.index_entity(updated)
|
|
81
79
|
|
|
82
80
|
# Handle deletions next
|
|
83
81
|
# remove rows from db for files no longer present
|
|
84
|
-
for
|
|
85
|
-
await self.handle_entity_deletion(
|
|
82
|
+
for path in changes.deleted:
|
|
83
|
+
await self.handle_entity_deletion(path)
|
|
86
84
|
|
|
87
85
|
# Parse files that need updating
|
|
88
86
|
parsed_entities: Dict[str, EntityMarkdown] = {}
|
|
89
87
|
|
|
90
|
-
for
|
|
91
|
-
entity_markdown = await self.entity_parser.parse_file(directory /
|
|
92
|
-
parsed_entities[
|
|
88
|
+
for path in [*changes.new, *changes.modified]:
|
|
89
|
+
entity_markdown = await self.entity_parser.parse_file(directory / path)
|
|
90
|
+
parsed_entities[path] = entity_markdown
|
|
93
91
|
|
|
94
92
|
# First pass: Create/update entities
|
|
95
93
|
# entities will have a null checksum to indicate they are not complete
|
|
96
|
-
for
|
|
97
|
-
|
|
94
|
+
for path, entity_markdown in parsed_entities.items():
|
|
98
95
|
# Get unique permalink and update markdown if needed
|
|
99
96
|
permalink = await self.entity_service.resolve_permalink(
|
|
100
|
-
|
|
101
|
-
markdown=entity_markdown
|
|
97
|
+
Path(path), markdown=entity_markdown
|
|
102
98
|
)
|
|
103
99
|
|
|
104
100
|
if permalink != entity_markdown.frontmatter.permalink:
|
|
105
101
|
# Add/update permalink in frontmatter
|
|
106
|
-
logger.info(f"Adding permalink '{permalink}' to file: {
|
|
102
|
+
logger.info(f"Adding permalink '{permalink}' to file: {path}")
|
|
107
103
|
|
|
108
104
|
# update markdown
|
|
109
105
|
entity_markdown.frontmatter.metadata["permalink"] = permalink
|
|
110
|
-
|
|
106
|
+
|
|
111
107
|
# update file frontmatter
|
|
112
108
|
updated_checksum = await file_utils.update_frontmatter(
|
|
113
|
-
directory /
|
|
114
|
-
{"permalink": permalink}
|
|
109
|
+
directory / path, {"permalink": permalink}
|
|
115
110
|
)
|
|
116
111
|
|
|
117
112
|
# Update checksum in changes report since file was modified
|
|
118
|
-
changes.checksums[
|
|
119
|
-
|
|
113
|
+
changes.checksums[path] = updated_checksum
|
|
114
|
+
|
|
120
115
|
# if the file is new, create an entity
|
|
121
|
-
if
|
|
116
|
+
if path in changes.new:
|
|
122
117
|
# Create entity with final permalink
|
|
123
|
-
logger.debug(f"Creating new entity_markdown: {
|
|
124
|
-
await self.entity_service.create_entity_from_markdown(
|
|
125
|
-
file_path, entity_markdown
|
|
126
|
-
)
|
|
118
|
+
logger.debug(f"Creating new entity_markdown: {path}")
|
|
119
|
+
await self.entity_service.create_entity_from_markdown(Path(path), entity_markdown)
|
|
127
120
|
# otherwise we need to update the entity and observations
|
|
128
121
|
else:
|
|
129
|
-
logger.debug(f"Updating entity_markdown: {
|
|
122
|
+
logger.debug(f"Updating entity_markdown: {path}")
|
|
130
123
|
await self.entity_service.update_entity_and_observations(
|
|
131
|
-
|
|
124
|
+
Path(path), entity_markdown
|
|
132
125
|
)
|
|
133
126
|
|
|
134
127
|
# Second pass
|
|
135
|
-
for
|
|
136
|
-
logger.debug(f"Updating relations for: {
|
|
128
|
+
for path, entity_markdown in parsed_entities.items():
|
|
129
|
+
logger.debug(f"Updating relations for: {path}")
|
|
137
130
|
|
|
138
131
|
# Process relations
|
|
139
|
-
checksum = changes.checksums[
|
|
140
|
-
entity = await self.entity_service.update_entity_relations(
|
|
141
|
-
file_path, entity_markdown
|
|
142
|
-
)
|
|
132
|
+
checksum = changes.checksums[path]
|
|
133
|
+
entity = await self.entity_service.update_entity_relations(Path(path), entity_markdown)
|
|
143
134
|
|
|
144
135
|
# add to search index
|
|
145
136
|
await self.search_service.index_entity(entity)
|
|
@@ -153,18 +144,22 @@ class SyncService:
|
|
|
153
144
|
target_entity = await self.entity_service.link_resolver.resolve_link(relation.to_name)
|
|
154
145
|
# check we found a link that is not the source
|
|
155
146
|
if target_entity and target_entity.id != relation.from_id:
|
|
156
|
-
logger.debug(
|
|
147
|
+
logger.debug(
|
|
148
|
+
f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}"
|
|
149
|
+
)
|
|
157
150
|
|
|
158
151
|
try:
|
|
159
|
-
await self.relation_repository.update(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
152
|
+
await self.relation_repository.update(
|
|
153
|
+
relation.id,
|
|
154
|
+
{
|
|
155
|
+
"to_id": target_entity.id,
|
|
156
|
+
"to_name": target_entity.title, # Update to actual title
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
except IntegrityError:
|
|
160
|
+
logger.debug(f"Ignoring duplicate relation {relation}")
|
|
165
161
|
|
|
166
162
|
# update search index
|
|
167
163
|
await self.search_service.index_entity(target_entity)
|
|
168
164
|
|
|
169
|
-
|
|
170
165
|
return changes
|
basic_memory/sync/utils.py
CHANGED
|
@@ -2,44 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
4
|
from typing import Set, Dict, Optional
|
|
5
|
-
from watchfiles import Change
|
|
6
|
-
from basic_memory.services.file_service import FileService
|
|
7
|
-
|
|
8
5
|
|
|
9
|
-
|
|
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
|
|
6
|
+
from watchfiles import Change
|
|
21
7
|
|
|
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
8
|
|
|
38
9
|
|
|
39
10
|
@dataclass
|
|
40
11
|
class SyncReport:
|
|
41
12
|
"""Report of file changes found compared to database state.
|
|
42
|
-
|
|
13
|
+
|
|
43
14
|
Attributes:
|
|
44
15
|
total: Total number of files in directory being synced
|
|
45
16
|
new: Files that exist on disk but not in database
|
|
@@ -48,19 +19,16 @@ class SyncReport:
|
|
|
48
19
|
moves: Files that have been moved from one location to another
|
|
49
20
|
checksums: Current checksums for files on disk
|
|
50
21
|
"""
|
|
22
|
+
|
|
51
23
|
total: int = 0
|
|
24
|
+
# We keep paths as strings in sets/dicts for easier serialization
|
|
52
25
|
new: Set[str] = field(default_factory=set)
|
|
53
26
|
modified: Set[str] = field(default_factory=set)
|
|
54
27
|
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)
|
|
28
|
+
moves: Dict[str, str] = field(default_factory=dict) # old_path -> new_path
|
|
29
|
+
checksums: Dict[str, str] = field(default_factory=dict) # path -> checksum
|
|
57
30
|
|
|
58
31
|
@property
|
|
59
32
|
def total_changes(self) -> int:
|
|
60
33
|
"""Total number of changes."""
|
|
61
34
|
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)
|
|
@@ -8,7 +8,6 @@ from datetime import datetime
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import List, Optional
|
|
10
10
|
|
|
11
|
-
from rich import box
|
|
12
11
|
from rich.console import Console
|
|
13
12
|
from rich.live import Live
|
|
14
13
|
from rich.table import Table
|
|
@@ -84,7 +83,7 @@ class WatchService:
|
|
|
84
83
|
|
|
85
84
|
def generate_table(self) -> Table:
|
|
86
85
|
"""Generate status display table"""
|
|
87
|
-
table = Table(
|
|
86
|
+
table = Table()
|
|
88
87
|
|
|
89
88
|
# Add status row
|
|
90
89
|
table.add_column("Status", style="cyan")
|
|
@@ -131,13 +130,36 @@ class WatchService:
|
|
|
131
130
|
|
|
132
131
|
return table
|
|
133
132
|
|
|
134
|
-
async def run(self):
|
|
133
|
+
async def run(self, console_status: bool = False): # pragma: no cover
|
|
135
134
|
"""Watch for file changes and sync them"""
|
|
135
|
+
logger.info("Watching for sync changes")
|
|
136
136
|
self.state.running = True
|
|
137
137
|
self.state.start_time = datetime.now()
|
|
138
138
|
await self.write_status()
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
if console_status:
|
|
141
|
+
with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live:
|
|
142
|
+
try:
|
|
143
|
+
async for changes in awatch(
|
|
144
|
+
self.config.home,
|
|
145
|
+
watch_filter=self.filter_changes,
|
|
146
|
+
debounce=self.config.sync_delay,
|
|
147
|
+
recursive=True,
|
|
148
|
+
):
|
|
149
|
+
# Process changes
|
|
150
|
+
await self.handle_changes(self.config.home)
|
|
151
|
+
# Update display
|
|
152
|
+
live.update(self.generate_table())
|
|
153
|
+
|
|
154
|
+
except Exception as e:
|
|
155
|
+
self.state.record_error(str(e))
|
|
156
|
+
await self.write_status()
|
|
157
|
+
raise
|
|
158
|
+
finally:
|
|
159
|
+
self.state.running = False
|
|
160
|
+
await self.write_status()
|
|
161
|
+
|
|
162
|
+
else:
|
|
141
163
|
try:
|
|
142
164
|
async for changes in awatch(
|
|
143
165
|
self.config.home,
|
|
@@ -148,7 +170,6 @@ class WatchService:
|
|
|
148
170
|
# Process changes
|
|
149
171
|
await self.handle_changes(self.config.home)
|
|
150
172
|
# Update display
|
|
151
|
-
live.update(self.generate_table())
|
|
152
173
|
|
|
153
174
|
except Exception as e:
|
|
154
175
|
self.state.record_error(str(e))
|
basic_memory/utils.py
CHANGED
|
@@ -1,38 +1,18 @@
|
|
|
1
1
|
"""Utility functions for basic-memory."""
|
|
2
|
+
|
|
2
3
|
import os
|
|
3
4
|
import re
|
|
4
|
-
import
|
|
5
|
+
import sys
|
|
5
6
|
from pathlib import Path
|
|
7
|
+
from typing import Optional, Union
|
|
6
8
|
|
|
9
|
+
from loguru import logger
|
|
7
10
|
from unidecode import unidecode
|
|
8
11
|
|
|
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
|
|
12
|
+
from basic_memory.config import config
|
|
33
13
|
|
|
34
14
|
|
|
35
|
-
def generate_permalink(file_path: Path
|
|
15
|
+
def generate_permalink(file_path: Union[Path, str]) -> str:
|
|
36
16
|
"""Generate a stable permalink from a file path.
|
|
37
17
|
|
|
38
18
|
Args:
|
|
@@ -50,8 +30,11 @@ def generate_permalink(file_path: Path | str) -> str:
|
|
|
50
30
|
>>> generate_permalink("design/unified_model_refactor.md")
|
|
51
31
|
'design/unified-model-refactor'
|
|
52
32
|
"""
|
|
33
|
+
# Convert Path to string if needed
|
|
34
|
+
path_str = str(file_path)
|
|
35
|
+
|
|
53
36
|
# Remove extension
|
|
54
|
-
base = os.path.splitext(
|
|
37
|
+
base = os.path.splitext(path_str)[0]
|
|
55
38
|
|
|
56
39
|
# Transliterate unicode to ascii
|
|
57
40
|
ascii_text = unidecode(base)
|
|
@@ -63,16 +46,42 @@ def generate_permalink(file_path: Path | str) -> str:
|
|
|
63
46
|
lower_text = ascii_text.lower()
|
|
64
47
|
|
|
65
48
|
# replace underscores with hyphens
|
|
66
|
-
text_with_hyphens = lower_text.replace(
|
|
49
|
+
text_with_hyphens = lower_text.replace("_", "-")
|
|
67
50
|
|
|
68
51
|
# Replace remaining invalid chars with hyphens
|
|
69
|
-
clean_text = re.sub(r
|
|
52
|
+
clean_text = re.sub(r"[^a-z0-9/\-]", "-", text_with_hyphens)
|
|
70
53
|
|
|
71
54
|
# Collapse multiple hyphens
|
|
72
|
-
clean_text = re.sub(r
|
|
55
|
+
clean_text = re.sub(r"-+", "-", clean_text)
|
|
73
56
|
|
|
74
57
|
# Clean each path segment
|
|
75
|
-
segments = clean_text.split(
|
|
76
|
-
clean_segments = [s.strip(
|
|
58
|
+
segments = clean_text.split("/")
|
|
59
|
+
clean_segments = [s.strip("-") for s in segments]
|
|
60
|
+
|
|
61
|
+
return "/".join(clean_segments)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def setup_logging(home_dir: Path = config.home, log_file: Optional[str] = None) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Configure logging for the application.
|
|
67
|
+
"""
|
|
77
68
|
|
|
78
|
-
|
|
69
|
+
# Remove default handler and any existing handlers
|
|
70
|
+
logger.remove()
|
|
71
|
+
|
|
72
|
+
# Add file handler
|
|
73
|
+
if log_file:
|
|
74
|
+
log_path = home_dir / log_file
|
|
75
|
+
logger.add(
|
|
76
|
+
str(log_path), # loguru expects a string path
|
|
77
|
+
level=config.log_level,
|
|
78
|
+
rotation="100 MB",
|
|
79
|
+
retention="10 days",
|
|
80
|
+
backtrace=True,
|
|
81
|
+
diagnose=True,
|
|
82
|
+
enqueue=True,
|
|
83
|
+
colorize=False,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Add stderr handler
|
|
87
|
+
logger.add(sys.stderr, level=config.log_level, backtrace=True, diagnose=True, colorize=True)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: basic-memory
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Local-first knowledge management combining Zettelkasten with knowledge graphs
|
|
5
5
|
Project-URL: Homepage, https://github.com/basicmachines-co/basic-memory
|
|
6
6
|
Project-URL: Repository, https://github.com/basicmachines-co/basic-memory
|
|
@@ -23,17 +23,12 @@ Requires-Dist: pydantic[email,timezone]>=2.10.3
|
|
|
23
23
|
Requires-Dist: pyright>=1.1.390
|
|
24
24
|
Requires-Dist: python-frontmatter>=1.1.0
|
|
25
25
|
Requires-Dist: pyyaml>=6.0.1
|
|
26
|
+
Requires-Dist: qasync>=0.27.1
|
|
26
27
|
Requires-Dist: rich>=13.9.4
|
|
27
28
|
Requires-Dist: sqlalchemy>=2.0.0
|
|
28
29
|
Requires-Dist: typer>=0.9.0
|
|
29
30
|
Requires-Dist: unidecode>=1.3.8
|
|
30
31
|
Requires-Dist: watchfiles>=1.0.4
|
|
31
|
-
Provides-Extra: dev
|
|
32
|
-
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
33
|
-
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
34
|
-
Requires-Dist: pytest-mock>=3.12.0; extra == 'dev'
|
|
35
|
-
Requires-Dist: pytest>=8.3.4; extra == 'dev'
|
|
36
|
-
Requires-Dist: ruff>=0.1.6; extra == 'dev'
|
|
37
32
|
Description-Content-Type: text/markdown
|
|
38
33
|
|
|
39
34
|
# Basic Memory
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
basic_memory/__init__.py,sha256=_ij75bUYM3LqRQYHrJ1kLnDuUyauuHilEBF96OFw9hA,122
|
|
2
|
+
basic_memory/config.py,sha256=PZA2qgwKACvKfRcM3H-BPB_8FYVhgZAwTmlKJ3ROfhU,1643
|
|
3
|
+
basic_memory/db.py,sha256=BFZCp4aJ7Xj9_ZCMz0rnSBuCy5xIMvvWjSImmuKzdWg,4605
|
|
4
|
+
basic_memory/deps.py,sha256=UzivBw6e6iYcU_8SQ8LNCmSsmFyHfjdzfWvnfNzqbRc,5375
|
|
5
|
+
basic_memory/file_utils.py,sha256=gp7RCFWaddFnELIyTc1E19Rk8jJsrKshG2n8ZZR-kKA,5751
|
|
6
|
+
basic_memory/utils.py,sha256=HiLorP5_YCQeNeTcDqvnkrwY7OBaFRS3i_hdV9iWKLs,2374
|
|
7
|
+
basic_memory/alembic/README,sha256=MVlc9TYmr57RbhXET6QxgyCcwWP7w-vLkEsirENqiIQ,38
|
|
8
|
+
basic_memory/alembic/env.py,sha256=XqJVQhS41ba7NCPmmaSZ09_tbSLnwsY2bcpJpqx_ZTc,2107
|
|
9
|
+
basic_memory/alembic/migrations.py,sha256=CIbkMHEKZ60aDUhFGSQjv8kDNM7sazfvEYHGGcy1DBk,858
|
|
10
|
+
basic_memory/alembic/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dMj8,635
|
|
11
|
+
basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py,sha256=lTbWlAnd1es7xU99DoJgfaRe1_Kte8TL98riqeKGV80,4363
|
|
12
|
+
basic_memory/api/__init__.py,sha256=wCpj-21j1D0KzKl9Ql6unLBVFY0K1uGp_FeSZRKtqpk,72
|
|
13
|
+
basic_memory/api/app.py,sha256=AEHcslN4SBq5Ni7q7wkG4jDH0-SwMWV2DeTdaUSQKns,2083
|
|
14
|
+
basic_memory/api/routers/__init__.py,sha256=iviQ1QVYobC8huUuyRhEjcA0BDjrOUm1lXHXhJkxP9A,239
|
|
15
|
+
basic_memory/api/routers/knowledge_router.py,sha256=cMLhRczOfSRnsZdyR0bSS8PENPRTu70dlwaV27O34bs,5705
|
|
16
|
+
basic_memory/api/routers/memory_router.py,sha256=pF0GzmWoxmjhtxZM8jCmfLwqjey_fmXER5vYbD8fsQw,4556
|
|
17
|
+
basic_memory/api/routers/resource_router.py,sha256=_Gp5HSJr-L-GUkQKbEP2bAZvCY8Smd-sBNWpGyqXS4c,1056
|
|
18
|
+
basic_memory/api/routers/search_router.py,sha256=dCRnBbp3r966U8UYwgAaxZBbg7yX7pC8QJqagdACUi0,1086
|
|
19
|
+
basic_memory/cli/__init__.py,sha256=arcKLAWRDhPD7x5t80MlviZeYzwHZ0GZigyy3NKVoGk,33
|
|
20
|
+
basic_memory/cli/app.py,sha256=hF4MgYCgFql4J6qi3lguqc6HQdP2gm6PpvtSxKBSjZc,34
|
|
21
|
+
basic_memory/cli/main.py,sha256=Vvpmh33MSZJftCENEjzJH3yBbxD4B40Pl6IBIumiVX4,505
|
|
22
|
+
basic_memory/cli/commands/__init__.py,sha256=OQGLaKTsOdPsp2INM_pHzmOlbVfdL0sytBNgvqTqCDY,159
|
|
23
|
+
basic_memory/cli/commands/db.py,sha256=I92CRufPskvHl9c90f5Eg7U7D0uIzLBiwngQuAh5cLk,772
|
|
24
|
+
basic_memory/cli/commands/import_memory_json.py,sha256=ZXSRHH_3GgJzmMLvDulakKIpzsKxrZIUmEuWgJmwMOE,5138
|
|
25
|
+
basic_memory/cli/commands/mcp.py,sha256=a0v54iFL01_eykODHuWIupTHCn-COm-WZGdSO5iinc0,563
|
|
26
|
+
basic_memory/cli/commands/status.py,sha256=aNpP8u-ECoVTiL5MIb-D2cXXLJtv6z2z8CMCh5nt2KY,5782
|
|
27
|
+
basic_memory/cli/commands/sync.py,sha256=sb6OGl9IVZLmGfHUm0-aexD365BRTaHJhpwqt0O5yxk,7035
|
|
28
|
+
basic_memory/markdown/__init__.py,sha256=DdzioCWtDnKaq05BHYLgL_78FawEHLpLXnp-kPSVfIc,501
|
|
29
|
+
basic_memory/markdown/entity_parser.py,sha256=sJk8TRUd9cAaIjATiJn7dBQRorrYngRbd7MRVfc0Oc4,3781
|
|
30
|
+
basic_memory/markdown/markdown_processor.py,sha256=mV3pYoDTaQMEl1tA5n_XztBvNlYyH2SzKs4vnKdAet4,4952
|
|
31
|
+
basic_memory/markdown/plugins.py,sha256=gtIzKRjoZsyvBqLpVNnrmzl_cbTZ5ZGn8kcuXxQjRko,6639
|
|
32
|
+
basic_memory/markdown/schemas.py,sha256=mzVEDUhH98kwETMknjkKw5H697vg_zUapsJkJVi17ho,1894
|
|
33
|
+
basic_memory/markdown/utils.py,sha256=ZtHa-dG--ZwFEUC3jfl04KZGhM_ZWo5b-8d8KpJ90gY,2758
|
|
34
|
+
basic_memory/mcp/__init__.py,sha256=dsDOhKqjYeIbCULbHIxfcItTbqudEuEg1Np86eq0GEQ,35
|
|
35
|
+
basic_memory/mcp/async_client.py,sha256=Eo345wANiBRSM4u3j_Vd6Ax4YtMg7qbWd9PIoFfj61I,236
|
|
36
|
+
basic_memory/mcp/server.py,sha256=L92Vit7llaKT9NlPZfxdp67C33niObmRH2QFyUhmnD0,355
|
|
37
|
+
basic_memory/mcp/tools/__init__.py,sha256=MHZmWw016N0qbtC3f186Jg1tPzh2g88_ZsCKJ0oyrrs,873
|
|
38
|
+
basic_memory/mcp/tools/knowledge.py,sha256=2U8YUKCizsAETHCC1mBVKMfCEef6tlc_pa2wOmA9mD4,2016
|
|
39
|
+
basic_memory/mcp/tools/memory.py,sha256=gl4MBm9l2lMOfu_xmUqjoZacWSIHOAYZiAm8z7oDuY8,5203
|
|
40
|
+
basic_memory/mcp/tools/notes.py,sha256=4GKnhDK53UkeZtpZENQ9id9XdemKxLzGwMQJeuX-Kok,3772
|
|
41
|
+
basic_memory/mcp/tools/search.py,sha256=tx6aIuB2FWmmrvzu3RHSQvszlk-zHcwrWhkLLHWjuZc,1105
|
|
42
|
+
basic_memory/mcp/tools/utils.py,sha256=icm-Xyqw3GxooGYkXqjEjoZvIGy_Z3CPw-uUYBxR_YQ,4831
|
|
43
|
+
basic_memory/models/__init__.py,sha256=Bf0xXV_ryndogvZDiVM_Wb6iV2fHUxYNGMZNWNcZi0s,307
|
|
44
|
+
basic_memory/models/base.py,sha256=4hAXJ8CE1RnjKhb23lPd-QM7G_FXIdTowMJ9bRixspU,225
|
|
45
|
+
basic_memory/models/knowledge.py,sha256=R05mLr2GXDfUcmPe2ja20wvzP818b4npnxL1PvQooEY,5921
|
|
46
|
+
basic_memory/models/search.py,sha256=IB-ySJUqlQq9FqLGfWnraIFcB_brWa9eBwsQP1rVTeI,1164
|
|
47
|
+
basic_memory/repository/__init__.py,sha256=TnscLXARq2iOgQZFvQoT9X1Bn9SB_7s1xw2fOqRs3Jg,252
|
|
48
|
+
basic_memory/repository/entity_repository.py,sha256=VFLymzJ1W6AZru_s1S3U6nlqSprBrVV5Toy0-qysIfw,3524
|
|
49
|
+
basic_memory/repository/observation_repository.py,sha256=BOcy4wARqCXu-thYyt7mPxt2A2C8TW0le3s_X9wrK6I,1701
|
|
50
|
+
basic_memory/repository/relation_repository.py,sha256=DwpTcn9z_1sZQcyMOUABz1k1VSwo_AU63x2zR7aerTk,2933
|
|
51
|
+
basic_memory/repository/repository.py,sha256=jUScHWOfcB2FajwVZ2Sbjtg-gSI2Y2rhiIaTULjvmn8,11321
|
|
52
|
+
basic_memory/repository/search_repository.py,sha256=OfocJZ7EWum33klFFvsLE7BEUnZPda1BNSwrbkRiXko,9233
|
|
53
|
+
basic_memory/schemas/__init__.py,sha256=eVxrtuPT7-9JIQ7UDx2J8t8xlS3u0iUkV_VLNbzvxo4,1575
|
|
54
|
+
basic_memory/schemas/base.py,sha256=epSauNNVZ2lRLATf-HIzqeberq4ZBTgxliNmjitAsWc,5538
|
|
55
|
+
basic_memory/schemas/delete.py,sha256=UAR2JK99WMj3gP-yoGWlHD3eZEkvlTSRf8QoYIE-Wfw,1180
|
|
56
|
+
basic_memory/schemas/discovery.py,sha256=6Y2tUiv9f06rFTsa8_wTH2haS2bhCfuQh0uW33hwdd8,876
|
|
57
|
+
basic_memory/schemas/memory.py,sha256=mqslazV0lQswtbNgYv_y2-KxmifIvRlg5I3IuTTMnO4,2882
|
|
58
|
+
basic_memory/schemas/request.py,sha256=rt_guNWrUMePJvDmsh1g1dc7IqEY6K6mGXMKx8tBCj8,1614
|
|
59
|
+
basic_memory/schemas/response.py,sha256=2su3YP-gkbw4MvgGtgZLHEuTp6RuVlK736KakaV7fP4,6273
|
|
60
|
+
basic_memory/schemas/search.py,sha256=pWBA1-xEQ3rH8vLIgrQT4oygq9MMwr0B7VCbFafVVOw,3278
|
|
61
|
+
basic_memory/services/__init__.py,sha256=oop6SKmzV4_NAYt9otGnupLGVCCKIVgxEcdRQWwh25I,197
|
|
62
|
+
basic_memory/services/context_service.py,sha256=Bu1wVl9q3FDGbGChrLqgFGQW95-W1OfjNqq6SGljqWg,9388
|
|
63
|
+
basic_memory/services/entity_service.py,sha256=bm_Z63_AJmXiRQkVYWwoB3PYLMW1t1xS3Nh0Nm9SwiI,11538
|
|
64
|
+
basic_memory/services/exceptions.py,sha256=VGlCLd4UD2w5NWKqC7QpG4jOM_hA7jKRRM-MqvEVMNk,288
|
|
65
|
+
basic_memory/services/file_service.py,sha256=r4JfPY1wyenAH0Y-iq7vGHPwT616ayUWoLnvA1NuzpA,5695
|
|
66
|
+
basic_memory/services/link_resolver.py,sha256=VdhoPAVa65T6LW7kSTLWts55zbnnN481fr7VLz3HaXE,4513
|
|
67
|
+
basic_memory/services/search_service.py,sha256=iB-BgFwInrJxTfYBerj68QORlMv46wYy2-ceQx61Dd8,7839
|
|
68
|
+
basic_memory/services/service.py,sha256=V-d_8gOV07zGIQDpL-Ksqs3ZN9l3qf3HZOK1f_YNTag,336
|
|
69
|
+
basic_memory/sync/__init__.py,sha256=ko0xLQv1S5U7sAOmIP2XKl03akVPzoY-a9m3TFPcMh4,193
|
|
70
|
+
basic_memory/sync/file_change_scanner.py,sha256=4whJej6t9sxwUp1ox93efJ0bBHSnAr6STpk_PsKU6to,5784
|
|
71
|
+
basic_memory/sync/sync_service.py,sha256=nAOX4N90lbpRJeq5tRR_7PYptIoWwhXMUljE7yrneF4,7087
|
|
72
|
+
basic_memory/sync/utils.py,sha256=uc7VLK34HufKyKavGwTPGU-ARfoQr_jYbjs4fsmUvuo,1233
|
|
73
|
+
basic_memory/sync/watch_service.py,sha256=CtKBrP1imI3ZSEgJl7Ffi-JZ_oDGKrhiyGgs41h5QYI,7563
|
|
74
|
+
basic_memory-0.1.2.dist-info/METADATA,sha256=QW_mPiSlc0TltyO1DCB5B5MXxiv-g5JOnZt2Y3LtV3o,7539
|
|
75
|
+
basic_memory-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
76
|
+
basic_memory-0.1.2.dist-info/entry_points.txt,sha256=IDQa_VmVTzmvMrpnjhEfM0S3F--XsVGEj3MpdJfuo-Q,59
|
|
77
|
+
basic_memory-0.1.2.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
78
|
+
basic_memory-0.1.2.dist-info/RECORD,,
|
basic_memory/mcp/main.py
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
"""Main MCP entrypoint for Basic Memory.
|
|
2
|
-
|
|
3
|
-
Creates and configures the shared MCP instance and handles server startup.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
from loguru import logger
|
|
7
|
-
|
|
8
|
-
from basic_memory.config import config
|
|
9
|
-
|
|
10
|
-
# Import shared mcp instance
|
|
11
|
-
from basic_memory.mcp.server import mcp
|
|
12
|
-
|
|
13
|
-
# Import tools to register them
|
|
14
|
-
import basic_memory.mcp.tools # noqa: F401
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if __name__ == "__main__":
|
|
18
|
-
home_dir = config.home
|
|
19
|
-
logger.info("Starting Basic Memory MCP server")
|
|
20
|
-
logger.info(f"Home directory: {home_dir}")
|
|
21
|
-
mcp.run()
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
"""Tool for AI-assisted file editing."""
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from typing import List, Dict, Any
|
|
5
|
-
|
|
6
|
-
from basic_memory.mcp.server import mcp
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def _detect_indent(text: str, match_pos: int) -> int:
|
|
10
|
-
"""Get indentation level at a position in text."""
|
|
11
|
-
# Find start of line containing the match
|
|
12
|
-
line_start = text.rfind("\n", 0, match_pos)
|
|
13
|
-
if line_start < 0:
|
|
14
|
-
line_start = 0
|
|
15
|
-
else:
|
|
16
|
-
line_start += 1 # Skip newline char
|
|
17
|
-
|
|
18
|
-
# Count leading spaces
|
|
19
|
-
pos = line_start
|
|
20
|
-
while pos < len(text) and text[pos].isspace():
|
|
21
|
-
pos += 1
|
|
22
|
-
return pos - line_start
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _apply_indent(text: str, spaces: int) -> str:
|
|
26
|
-
"""Apply indentation to text."""
|
|
27
|
-
prefix = " " * spaces
|
|
28
|
-
return "\n".join(prefix + line if line.strip() else line for line in text.split("\n"))
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@mcp.tool()
|
|
32
|
-
async def ai_edit(path: str, edits: List[Dict[str, Any]]) -> bool:
|
|
33
|
-
"""AI-assisted file editing tool.
|
|
34
|
-
|
|
35
|
-
Args:
|
|
36
|
-
path: Path to file to edit
|
|
37
|
-
edits: List of edits to apply. Each edit is a dict with:
|
|
38
|
-
oldText: Text to replace
|
|
39
|
-
newText: New content
|
|
40
|
-
options: Optional dict with:
|
|
41
|
-
indent: Number of spaces to indent
|
|
42
|
-
preserveIndentation: Keep existing indent (default: true)
|
|
43
|
-
|
|
44
|
-
Returns:
|
|
45
|
-
bool: True if edits were applied successfully
|
|
46
|
-
"""
|
|
47
|
-
try:
|
|
48
|
-
# Read file
|
|
49
|
-
content = Path(path).read_text()
|
|
50
|
-
original = content
|
|
51
|
-
success = True
|
|
52
|
-
|
|
53
|
-
# Apply each edit
|
|
54
|
-
for edit in edits:
|
|
55
|
-
old_text = edit["oldText"]
|
|
56
|
-
new_text = edit["newText"]
|
|
57
|
-
options = edit.get("options", {})
|
|
58
|
-
|
|
59
|
-
# Find text to replace
|
|
60
|
-
match_pos = content.find(old_text)
|
|
61
|
-
if match_pos < 0:
|
|
62
|
-
success = False
|
|
63
|
-
continue
|
|
64
|
-
|
|
65
|
-
# Handle indentation
|
|
66
|
-
if not options.get("preserveIndentation", True):
|
|
67
|
-
# Use existing indentation
|
|
68
|
-
indent = _detect_indent(content, match_pos)
|
|
69
|
-
new_text = _apply_indent(new_text, indent)
|
|
70
|
-
elif "indent" in options:
|
|
71
|
-
# Use specified indentation
|
|
72
|
-
new_text = _apply_indent(new_text, options["indent"])
|
|
73
|
-
|
|
74
|
-
# Apply the edit
|
|
75
|
-
content = content.replace(old_text, new_text)
|
|
76
|
-
|
|
77
|
-
# Write back if changed
|
|
78
|
-
if content != original:
|
|
79
|
-
Path(path).write_text(content)
|
|
80
|
-
return success
|
|
81
|
-
|
|
82
|
-
except Exception as e:
|
|
83
|
-
print(f"Error applying edits: {e}")
|
|
84
|
-
return False
|