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.

Files changed (32) hide show
  1. basic_memory/api/app.py +23 -1
  2. basic_memory/api/routers/memory_router.py +1 -1
  3. basic_memory/cli/commands/__init__.py +3 -7
  4. basic_memory/cli/commands/import_memory_json.py +139 -0
  5. basic_memory/cli/main.py +3 -4
  6. basic_memory/config.py +5 -1
  7. basic_memory/db.py +45 -21
  8. basic_memory/file_utils.py +27 -62
  9. basic_memory/markdown/__init__.py +2 -0
  10. basic_memory/markdown/entity_parser.py +1 -1
  11. basic_memory/markdown/markdown_processor.py +2 -14
  12. basic_memory/markdown/plugins.py +1 -1
  13. basic_memory/markdown/schemas.py +1 -3
  14. basic_memory/mcp/tools/memory.py +8 -3
  15. basic_memory/models/__init__.py +9 -6
  16. basic_memory/models/base.py +4 -3
  17. basic_memory/repository/search_repository.py +10 -3
  18. basic_memory/schemas/base.py +5 -2
  19. basic_memory/schemas/memory.py +4 -1
  20. basic_memory/services/__init__.py +2 -1
  21. basic_memory/services/context_service.py +1 -1
  22. basic_memory/services/database_service.py +159 -0
  23. basic_memory/services/entity_service.py +52 -2
  24. basic_memory/services/file_service.py +0 -1
  25. basic_memory/sync/sync_service.py +34 -4
  26. basic_memory-0.1.1.dist-info/METADATA +296 -0
  27. {basic_memory-0.0.0.dist-info → basic_memory-0.1.1.dist-info}/RECORD +30 -29
  28. basic_memory/cli/commands/init.py +0 -38
  29. basic_memory-0.0.0.dist-info/METADATA +0 -71
  30. {basic_memory-0.0.0.dist-info → basic_memory-0.1.1.dist-info}/WHEEL +0 -0
  31. {basic_memory-0.0.0.dist-info → basic_memory-0.1.1.dist-info}/entry_points.txt +0 -0
  32. {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(
@@ -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 the path ID in format {snake_case_title}."""
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
@@ -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
- type: str
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"file_path {file_path} for entity {schema.permalink} already exists: {file_path}"
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
@@ -35,7 +35,6 @@ class FileService:
35
35
  """Generate absolute filesystem path for entity."""
36
36
  return self.base_path / f"{entity.file_path}"
37
37
 
38
- # TODO move to tests
39
38
  async def write_entity_file(
40
39
  self,
41
40
  entity: EntityModel,
@@ -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
- await self.relation_repository.update(relation.id, {
132
- "to_id": target_entity.id,
133
- "to_name": target_entity.title # Update to actual title
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)