basic-memory 0.0.0__py3-none-any.whl → 0.1.1__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/api/app.py +23 -1
- basic_memory/api/routers/memory_router.py +1 -1
- basic_memory/cli/commands/__init__.py +3 -7
- basic_memory/cli/commands/import_memory_json.py +139 -0
- basic_memory/cli/main.py +3 -4
- basic_memory/config.py +5 -1
- basic_memory/db.py +45 -21
- basic_memory/file_utils.py +27 -62
- basic_memory/markdown/__init__.py +2 -0
- basic_memory/markdown/entity_parser.py +1 -1
- basic_memory/markdown/markdown_processor.py +2 -14
- basic_memory/markdown/plugins.py +1 -1
- basic_memory/markdown/schemas.py +1 -3
- basic_memory/mcp/tools/memory.py +8 -3
- basic_memory/models/__init__.py +9 -6
- basic_memory/models/base.py +4 -3
- basic_memory/repository/search_repository.py +10 -3
- basic_memory/schemas/base.py +5 -2
- basic_memory/schemas/memory.py +4 -1
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +1 -1
- basic_memory/services/database_service.py +159 -0
- basic_memory/services/entity_service.py +52 -2
- basic_memory/services/file_service.py +0 -1
- basic_memory/sync/sync_service.py +34 -4
- basic_memory-0.1.1.dist-info/METADATA +296 -0
- {basic_memory-0.0.0.dist-info → basic_memory-0.1.1.dist-info}/RECORD +30 -29
- basic_memory/cli/commands/init.py +0 -38
- basic_memory-0.0.0.dist-info/METADATA +0 -71
- {basic_memory-0.0.0.dist-info → basic_memory-0.1.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.0.0.dist-info → basic_memory-0.1.1.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.0.0.dist-info → basic_memory-0.1.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -70,6 +70,8 @@ class SearchRepository:
|
|
|
70
70
|
|
|
71
71
|
async def init_search_index(self):
|
|
72
72
|
"""Create or recreate the search index."""
|
|
73
|
+
|
|
74
|
+
logger.info("Initializing search index")
|
|
73
75
|
async with db.scoped_session(self.session_maker) as session:
|
|
74
76
|
await session.execute(CREATE_SEARCH_INDEX)
|
|
75
77
|
await session.commit()
|
|
@@ -95,14 +97,15 @@ class SearchRepository:
|
|
|
95
97
|
permalink_match: Optional[str] = None,
|
|
96
98
|
title: Optional[str] = None,
|
|
97
99
|
types: List[SearchItemType] = None,
|
|
98
|
-
after_date: datetime = None,
|
|
100
|
+
after_date: Optional[datetime] = None,
|
|
99
101
|
entity_types: List[str] = None,
|
|
100
102
|
limit: int = 10,
|
|
101
103
|
) -> List[SearchIndexRow]:
|
|
102
104
|
"""Search across all indexed content with fuzzy matching."""
|
|
103
105
|
conditions = []
|
|
104
106
|
params = {}
|
|
105
|
-
|
|
107
|
+
order_by_clause = ""
|
|
108
|
+
|
|
106
109
|
# Handle text search for title and content
|
|
107
110
|
if search_text:
|
|
108
111
|
search_text = self._quote_search_term(search_text.lower().strip())
|
|
@@ -139,6 +142,9 @@ class SearchRepository:
|
|
|
139
142
|
if after_date:
|
|
140
143
|
params["after_date"] = after_date
|
|
141
144
|
conditions.append("datetime(created_at) > datetime(:after_date)")
|
|
145
|
+
|
|
146
|
+
# order by most recent first
|
|
147
|
+
order_by_clause = ", updated_at DESC"
|
|
142
148
|
|
|
143
149
|
# set limit on search query
|
|
144
150
|
params["limit"] = limit
|
|
@@ -165,7 +171,7 @@ class SearchRepository:
|
|
|
165
171
|
bm25(search_index) as score
|
|
166
172
|
FROM search_index
|
|
167
173
|
WHERE {where_clause}
|
|
168
|
-
ORDER BY score ASC
|
|
174
|
+
ORDER BY score ASC {order_by_clause}
|
|
169
175
|
LIMIT :limit
|
|
170
176
|
"""
|
|
171
177
|
|
|
@@ -197,6 +203,7 @@ class SearchRepository:
|
|
|
197
203
|
|
|
198
204
|
#for r in results:
|
|
199
205
|
# logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
|
|
206
|
+
|
|
200
207
|
return results
|
|
201
208
|
|
|
202
209
|
async def index_item(
|
basic_memory/schemas/base.py
CHANGED
|
@@ -181,6 +181,9 @@ class Entity(BaseModel):
|
|
|
181
181
|
- Optional relations to other entities
|
|
182
182
|
- Optional description for high-level overview
|
|
183
183
|
"""
|
|
184
|
+
|
|
185
|
+
# private field to override permalink
|
|
186
|
+
_permalink: Optional[str] = None
|
|
184
187
|
|
|
185
188
|
title: str
|
|
186
189
|
content: Optional[str] = None
|
|
@@ -199,8 +202,8 @@ class Entity(BaseModel):
|
|
|
199
202
|
|
|
200
203
|
@property
|
|
201
204
|
def permalink(self) -> PathId:
|
|
202
|
-
"""Get
|
|
203
|
-
return generate_permalink(self.file_path)
|
|
205
|
+
"""Get a url friendly path}."""
|
|
206
|
+
return self._permalink or generate_permalink(self.file_path)
|
|
204
207
|
|
|
205
208
|
@model_validator(mode="after")
|
|
206
209
|
@classmethod
|
basic_memory/schemas/memory.py
CHANGED
|
@@ -59,6 +59,7 @@ def memory_url_path(url: memory_url) -> str:
|
|
|
59
59
|
class EntitySummary(BaseModel):
|
|
60
60
|
"""Simplified entity representation."""
|
|
61
61
|
|
|
62
|
+
type: str = "entity"
|
|
62
63
|
permalink: str
|
|
63
64
|
title: str
|
|
64
65
|
file_path: str
|
|
@@ -68,8 +69,9 @@ class EntitySummary(BaseModel):
|
|
|
68
69
|
class RelationSummary(BaseModel):
|
|
69
70
|
"""Simplified relation representation."""
|
|
70
71
|
|
|
72
|
+
type: str = "relation"
|
|
71
73
|
permalink: str
|
|
72
|
-
|
|
74
|
+
relation_type: str
|
|
73
75
|
from_id: str
|
|
74
76
|
to_id: Optional[str] = None
|
|
75
77
|
|
|
@@ -77,6 +79,7 @@ class RelationSummary(BaseModel):
|
|
|
77
79
|
class ObservationSummary(BaseModel):
|
|
78
80
|
"""Simplified observation representation."""
|
|
79
81
|
|
|
82
|
+
type: str = "observation"
|
|
80
83
|
permalink: str
|
|
81
84
|
category: str
|
|
82
85
|
content: str
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""Services package."""
|
|
2
|
-
|
|
2
|
+
from .database_service import DatabaseService
|
|
3
3
|
from .service import BaseService
|
|
4
4
|
from .file_service import FileService
|
|
5
5
|
from .entity_service import EntityService
|
|
@@ -8,4 +8,5 @@ __all__ = [
|
|
|
8
8
|
"BaseService",
|
|
9
9
|
"FileService",
|
|
10
10
|
"EntityService",
|
|
11
|
+
"DatabaseService"
|
|
11
12
|
]
|
|
@@ -76,7 +76,7 @@ class ContextService:
|
|
|
76
76
|
primary = await self.search_repository.search(permalink=path)
|
|
77
77
|
else:
|
|
78
78
|
logger.debug(f"Build context for '{types}'")
|
|
79
|
-
primary = await self.search_repository.search(types=types)
|
|
79
|
+
primary = await self.search_repository.search(types=types, after_date=since)
|
|
80
80
|
|
|
81
81
|
# Get type_id pairs for traversal
|
|
82
82
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Service for managing database lifecycle and schema validation."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Tuple, List
|
|
6
|
+
|
|
7
|
+
from alembic.runtime.migration import MigrationContext
|
|
8
|
+
from alembic.autogenerate import compare_metadata
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from sqlalchemy import MetaData
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
from basic_memory import db
|
|
14
|
+
from basic_memory.config import ProjectConfig
|
|
15
|
+
from basic_memory.models import Base
|
|
16
|
+
from basic_memory.repository.search_repository import SearchRepository
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def check_schema_matches_models(session: AsyncSession) -> Tuple[bool, List[str]]:
|
|
20
|
+
"""Check if database schema matches SQLAlchemy models.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
tuple[bool, list[str]]: (matches, list of differences)
|
|
24
|
+
"""
|
|
25
|
+
# Get current DB schema via migration context
|
|
26
|
+
conn = await session.connection()
|
|
27
|
+
|
|
28
|
+
def _compare_schemas(connection):
|
|
29
|
+
context = MigrationContext.configure(connection)
|
|
30
|
+
return compare_metadata(context, Base.metadata)
|
|
31
|
+
|
|
32
|
+
# Run comparison in sync context
|
|
33
|
+
differences = await conn.run_sync(_compare_schemas)
|
|
34
|
+
|
|
35
|
+
if not differences:
|
|
36
|
+
return True, []
|
|
37
|
+
|
|
38
|
+
# Format differences into readable messages
|
|
39
|
+
diff_messages = []
|
|
40
|
+
for diff in differences:
|
|
41
|
+
if diff[0] == 'add_table':
|
|
42
|
+
diff_messages.append(f"Missing table: {diff[1].name}")
|
|
43
|
+
elif diff[0] == 'remove_table':
|
|
44
|
+
diff_messages.append(f"Extra table: {diff[1].name}")
|
|
45
|
+
elif diff[0] == 'add_column':
|
|
46
|
+
diff_messages.append(f"Missing column: {diff[3]} in table {diff[2]}")
|
|
47
|
+
elif diff[0] == 'remove_column':
|
|
48
|
+
diff_messages.append(f"Extra column: {diff[3]} in table {diff[2]}")
|
|
49
|
+
elif diff[0] == 'modify_type':
|
|
50
|
+
diff_messages.append(f"Column type mismatch: {diff[3]} in table {diff[2]}")
|
|
51
|
+
|
|
52
|
+
return False, diff_messages
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DatabaseService:
|
|
56
|
+
"""Manages database lifecycle including schema validation and backups."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
config: ProjectConfig,
|
|
61
|
+
db_type: db.DatabaseType = db.DatabaseType.FILESYSTEM,
|
|
62
|
+
):
|
|
63
|
+
self.config = config
|
|
64
|
+
self.db_path = Path(config.database_path)
|
|
65
|
+
self.db_type = db_type
|
|
66
|
+
|
|
67
|
+
async def create_backup(self) -> Optional[Path]:
|
|
68
|
+
"""Create backup of existing database file.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Optional[Path]: Path to backup file if created, None if no DB exists
|
|
72
|
+
"""
|
|
73
|
+
if self.db_type == db.DatabaseType.MEMORY:
|
|
74
|
+
return None # Skip backups for in-memory DB
|
|
75
|
+
|
|
76
|
+
if not self.db_path.exists():
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# Create backup with timestamp
|
|
80
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
81
|
+
backup_path = self.db_path.with_suffix(f".{timestamp}.backup")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
self.db_path.rename(backup_path)
|
|
85
|
+
logger.info(f"Created database backup: {backup_path}")
|
|
86
|
+
|
|
87
|
+
# make a new empty file
|
|
88
|
+
self.db_path.touch()
|
|
89
|
+
return backup_path
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.error(f"Failed to create database backup: {e}")
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
async def initialize_db(self):
|
|
95
|
+
"""Initialize database with current schema."""
|
|
96
|
+
logger.info("Initializing database...")
|
|
97
|
+
|
|
98
|
+
if self.db_type == db.DatabaseType.FILESYSTEM:
|
|
99
|
+
await self.create_backup()
|
|
100
|
+
|
|
101
|
+
# Drop existing tables if any
|
|
102
|
+
await db.drop_db()
|
|
103
|
+
|
|
104
|
+
# Create tables with current schema
|
|
105
|
+
await db.get_or_create_db(
|
|
106
|
+
db_path=self.db_path,
|
|
107
|
+
db_type=self.db_type
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
logger.info("Database initialized with current schema")
|
|
111
|
+
|
|
112
|
+
async def check_db(self) -> bool:
|
|
113
|
+
"""Check database state and rebuild if schema doesn't match models.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
bool: True if DB is ready for use, False if initialization failed
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
_, session_maker = await db.get_or_create_db(
|
|
120
|
+
db_path=self.db_path,
|
|
121
|
+
db_type=self.db_type
|
|
122
|
+
)
|
|
123
|
+
async with db.scoped_session(session_maker) as db_session:
|
|
124
|
+
# Check actual schema matches
|
|
125
|
+
matches, differences = await check_schema_matches_models(db_session)
|
|
126
|
+
if not matches:
|
|
127
|
+
logger.warning("Database schema does not match models:")
|
|
128
|
+
for diff in differences:
|
|
129
|
+
logger.warning(f" {diff}")
|
|
130
|
+
logger.info("Rebuilding database to match current models...")
|
|
131
|
+
await self.initialize_db()
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
logger.info("Database schema matches models")
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error(f"Database initialization failed: {e}")
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
async def cleanup_backups(self, keep_count: int = 5):
|
|
142
|
+
"""Clean up old database backups, keeping the N most recent."""
|
|
143
|
+
if self.db_type == db.DatabaseType.MEMORY:
|
|
144
|
+
return # Skip cleanup for in-memory DB
|
|
145
|
+
|
|
146
|
+
backup_pattern = "*.backup" # Use relative pattern
|
|
147
|
+
backups = sorted(
|
|
148
|
+
self.db_path.parent.glob(backup_pattern),
|
|
149
|
+
key=lambda p: p.stat().st_mtime,
|
|
150
|
+
reverse=True,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Remove old backups
|
|
154
|
+
for backup in backups[keep_count:]:
|
|
155
|
+
try:
|
|
156
|
+
backup.unlink()
|
|
157
|
+
logger.debug(f"Removed old backup: {backup}")
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.error(f"Failed to remove backup {backup}: {e}")
|
|
@@ -19,6 +19,7 @@ from basic_memory.services import FileService
|
|
|
19
19
|
from basic_memory.services import BaseService
|
|
20
20
|
from basic_memory.services.link_resolver import LinkResolver
|
|
21
21
|
from basic_memory.markdown.entity_parser import EntityParser
|
|
22
|
+
from basic_memory.utils import generate_permalink
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
class EntityService(BaseService[EntityModel]):
|
|
@@ -40,6 +41,51 @@ class EntityService(BaseService[EntityModel]):
|
|
|
40
41
|
self.file_service = file_service
|
|
41
42
|
self.link_resolver = link_resolver
|
|
42
43
|
|
|
44
|
+
async def resolve_permalink(
|
|
45
|
+
self,
|
|
46
|
+
file_path: Path,
|
|
47
|
+
markdown: Optional[EntityMarkdown] = None
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Get or generate unique permalink for an entity.
|
|
50
|
+
|
|
51
|
+
Priority:
|
|
52
|
+
1. If markdown has permalink and it's not used by another file -> use as is
|
|
53
|
+
2. If markdown has permalink but it's used by another file -> make unique
|
|
54
|
+
3. For existing files, keep current permalink from db
|
|
55
|
+
4. Generate new unique permalink from file path
|
|
56
|
+
"""
|
|
57
|
+
file_path = str(file_path)
|
|
58
|
+
|
|
59
|
+
# If markdown has explicit permalink, try to validate it
|
|
60
|
+
if markdown and markdown.frontmatter.permalink:
|
|
61
|
+
desired_permalink = markdown.frontmatter.permalink
|
|
62
|
+
existing = await self.repository.get_by_permalink(desired_permalink)
|
|
63
|
+
|
|
64
|
+
# If no conflict or it's our own file, use as is
|
|
65
|
+
if not existing or existing.file_path == file_path:
|
|
66
|
+
return desired_permalink
|
|
67
|
+
|
|
68
|
+
# For existing files, try to find current permalink
|
|
69
|
+
existing = await self.repository.get_by_file_path(file_path)
|
|
70
|
+
if existing:
|
|
71
|
+
return existing.permalink
|
|
72
|
+
|
|
73
|
+
# New file - generate permalink
|
|
74
|
+
if markdown and markdown.frontmatter.permalink:
|
|
75
|
+
desired_permalink = markdown.frontmatter.permalink
|
|
76
|
+
else:
|
|
77
|
+
desired_permalink = generate_permalink(file_path)
|
|
78
|
+
|
|
79
|
+
# Make unique if needed
|
|
80
|
+
permalink = desired_permalink
|
|
81
|
+
suffix = 1
|
|
82
|
+
while await self.repository.get_by_permalink(permalink):
|
|
83
|
+
permalink = f"{desired_permalink}-{suffix}"
|
|
84
|
+
suffix += 1
|
|
85
|
+
logger.debug(f"creating unique permalink: {permalink}")
|
|
86
|
+
|
|
87
|
+
return permalink
|
|
88
|
+
|
|
43
89
|
async def create_or_update_entity(self, schema: EntitySchema) -> (EntityModel, bool):
|
|
44
90
|
"""Create new entity or update existing one.
|
|
45
91
|
if a new entity is created, the return value is (entity, True)
|
|
@@ -66,9 +112,13 @@ class EntityService(BaseService[EntityModel]):
|
|
|
66
112
|
|
|
67
113
|
if await self.file_service.exists(file_path):
|
|
68
114
|
raise EntityCreationError(
|
|
69
|
-
f"
|
|
115
|
+
f"file for entity {schema.folder}/{schema.title} already exists: {file_path}"
|
|
70
116
|
)
|
|
71
117
|
|
|
118
|
+
# Get unique permalink
|
|
119
|
+
permalink = await self.resolve_permalink(schema.permalink or file_path)
|
|
120
|
+
schema._permalink = permalink
|
|
121
|
+
|
|
72
122
|
post = await schema_to_markdown(schema)
|
|
73
123
|
|
|
74
124
|
# write file
|
|
@@ -184,7 +234,7 @@ class EntityService(BaseService[EntityModel]):
|
|
|
184
234
|
Creates the entity with null checksum to indicate sync not complete.
|
|
185
235
|
Relations will be added in second pass.
|
|
186
236
|
"""
|
|
187
|
-
logger.debug(f"Creating entity: {markdown.frontmatter.title}")
|
|
237
|
+
logger.debug(f"Creating entity: {markdown.frontmatter.title}")
|
|
188
238
|
model = entity_model_from_markdown(file_path, markdown)
|
|
189
239
|
|
|
190
240
|
# Mark as incomplete sync
|
|
@@ -4,7 +4,9 @@ from pathlib import Path
|
|
|
4
4
|
from typing import Dict
|
|
5
5
|
|
|
6
6
|
from loguru import logger
|
|
7
|
+
from sqlalchemy.exc import IntegrityError
|
|
7
8
|
|
|
9
|
+
from basic_memory import file_utils
|
|
8
10
|
from basic_memory.markdown import EntityParser, EntityMarkdown
|
|
9
11
|
from basic_memory.repository import EntityRepository, RelationRepository
|
|
10
12
|
from basic_memory.services import EntityService
|
|
@@ -92,8 +94,32 @@ class SyncService:
|
|
|
92
94
|
# First pass: Create/update entities
|
|
93
95
|
# entities will have a null checksum to indicate they are not complete
|
|
94
96
|
for file_path, entity_markdown in parsed_entities.items():
|
|
97
|
+
|
|
98
|
+
# Get unique permalink and update markdown if needed
|
|
99
|
+
permalink = await self.entity_service.resolve_permalink(
|
|
100
|
+
file_path,
|
|
101
|
+
markdown=entity_markdown
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if permalink != entity_markdown.frontmatter.permalink:
|
|
105
|
+
# Add/update permalink in frontmatter
|
|
106
|
+
logger.info(f"Adding permalink '{permalink}' to file: {file_path}")
|
|
107
|
+
|
|
108
|
+
# update markdown
|
|
109
|
+
entity_markdown.frontmatter.metadata["permalink"] = permalink
|
|
110
|
+
|
|
111
|
+
# update file frontmatter
|
|
112
|
+
updated_checksum = await file_utils.update_frontmatter(
|
|
113
|
+
directory / file_path,
|
|
114
|
+
{"permalink": permalink}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Update checksum in changes report since file was modified
|
|
118
|
+
changes.checksums[file_path] = updated_checksum
|
|
119
|
+
|
|
95
120
|
# if the file is new, create an entity
|
|
96
121
|
if file_path in changes.new:
|
|
122
|
+
# Create entity with final permalink
|
|
97
123
|
logger.debug(f"Creating new entity_markdown: {file_path}")
|
|
98
124
|
await self.entity_service.create_entity_from_markdown(
|
|
99
125
|
file_path, entity_markdown
|
|
@@ -128,10 +154,14 @@ class SyncService:
|
|
|
128
154
|
# check we found a link that is not the source
|
|
129
155
|
if target_entity and target_entity.id != relation.from_id:
|
|
130
156
|
logger.debug(f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}")
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
await self.relation_repository.update(relation.id, {
|
|
160
|
+
"to_id": target_entity.id,
|
|
161
|
+
"to_name": target_entity.title # Update to actual title
|
|
162
|
+
})
|
|
163
|
+
except IntegrityError as e:
|
|
164
|
+
logger.info(f"Ignoring duplicate relation {relation}")
|
|
135
165
|
|
|
136
166
|
# update search index
|
|
137
167
|
await self.search_service.index_entity(target_entity)
|