basic-memory 0.14.2__py3-none-any.whl → 0.14.4__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/env.py +3 -1
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +53 -0
- basic_memory/api/app.py +4 -1
- basic_memory/api/routers/management_router.py +3 -1
- basic_memory/api/routers/project_router.py +21 -13
- basic_memory/api/routers/resource_router.py +3 -3
- basic_memory/cli/app.py +3 -3
- basic_memory/cli/commands/__init__.py +1 -2
- basic_memory/cli/commands/db.py +5 -5
- basic_memory/cli/commands/import_chatgpt.py +3 -2
- basic_memory/cli/commands/import_claude_conversations.py +3 -1
- basic_memory/cli/commands/import_claude_projects.py +3 -1
- basic_memory/cli/commands/import_memory_json.py +5 -2
- basic_memory/cli/commands/mcp.py +3 -15
- basic_memory/cli/commands/project.py +46 -6
- basic_memory/cli/commands/status.py +4 -1
- basic_memory/cli/commands/sync.py +10 -2
- basic_memory/cli/main.py +0 -1
- basic_memory/config.py +61 -34
- basic_memory/db.py +2 -6
- basic_memory/deps.py +3 -2
- basic_memory/file_utils.py +65 -0
- basic_memory/importers/chatgpt_importer.py +20 -10
- basic_memory/importers/memory_json_importer.py +22 -7
- basic_memory/importers/utils.py +2 -2
- basic_memory/markdown/entity_parser.py +2 -2
- basic_memory/markdown/markdown_processor.py +2 -2
- basic_memory/markdown/plugins.py +42 -26
- basic_memory/markdown/utils.py +1 -1
- basic_memory/mcp/async_client.py +22 -2
- basic_memory/mcp/project_session.py +6 -4
- basic_memory/mcp/prompts/__init__.py +0 -2
- basic_memory/mcp/server.py +8 -71
- basic_memory/mcp/tools/build_context.py +12 -2
- basic_memory/mcp/tools/move_note.py +24 -12
- basic_memory/mcp/tools/project_management.py +22 -7
- basic_memory/mcp/tools/read_content.py +16 -0
- basic_memory/mcp/tools/read_note.py +17 -2
- basic_memory/mcp/tools/sync_status.py +3 -2
- basic_memory/mcp/tools/write_note.py +9 -1
- basic_memory/models/knowledge.py +13 -2
- basic_memory/models/project.py +3 -3
- basic_memory/repository/entity_repository.py +2 -2
- basic_memory/repository/project_repository.py +19 -1
- basic_memory/repository/search_repository.py +7 -3
- basic_memory/schemas/base.py +40 -10
- basic_memory/schemas/importer.py +1 -0
- basic_memory/schemas/memory.py +23 -11
- basic_memory/services/context_service.py +12 -2
- basic_memory/services/directory_service.py +7 -0
- basic_memory/services/entity_service.py +56 -10
- basic_memory/services/initialization.py +0 -75
- basic_memory/services/project_service.py +93 -36
- basic_memory/sync/background_sync.py +4 -3
- basic_memory/sync/sync_service.py +53 -4
- basic_memory/sync/watch_service.py +31 -8
- basic_memory/utils.py +234 -71
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/METADATA +21 -92
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/RECORD +63 -68
- basic_memory/cli/commands/auth.py +0 -136
- basic_memory/mcp/auth_provider.py +0 -270
- basic_memory/mcp/external_auth_provider.py +0 -321
- basic_memory/mcp/prompts/sync_status.py +0 -112
- basic_memory/mcp/supabase_auth_provider.py +0 -463
- basic_memory/services/migration_service.py +0 -168
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/config.py
CHANGED
|
@@ -46,7 +46,7 @@ class BasicMemoryConfig(BaseSettings):
|
|
|
46
46
|
|
|
47
47
|
projects: Dict[str, str] = Field(
|
|
48
48
|
default_factory=lambda: {
|
|
49
|
-
"main":
|
|
49
|
+
"main": Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")).as_posix()
|
|
50
50
|
},
|
|
51
51
|
description="Mapping of project names to their filesystem paths",
|
|
52
52
|
)
|
|
@@ -74,6 +74,17 @@ class BasicMemoryConfig(BaseSettings):
|
|
|
74
74
|
description="Whether to sync changes in real time. default (True)",
|
|
75
75
|
)
|
|
76
76
|
|
|
77
|
+
kebab_filenames: bool = Field(
|
|
78
|
+
default=False,
|
|
79
|
+
description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# API connection configuration
|
|
83
|
+
api_url: Optional[str] = Field(
|
|
84
|
+
default=None,
|
|
85
|
+
description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.",
|
|
86
|
+
)
|
|
87
|
+
|
|
77
88
|
model_config = SettingsConfigDict(
|
|
78
89
|
env_prefix="BASIC_MEMORY_",
|
|
79
90
|
extra="ignore",
|
|
@@ -94,9 +105,9 @@ class BasicMemoryConfig(BaseSettings):
|
|
|
94
105
|
"""Ensure configuration is valid after initialization."""
|
|
95
106
|
# Ensure main project exists
|
|
96
107
|
if "main" not in self.projects: # pragma: no cover
|
|
97
|
-
self.projects["main"] =
|
|
108
|
+
self.projects["main"] = (
|
|
98
109
|
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
|
|
99
|
-
)
|
|
110
|
+
).as_posix()
|
|
100
111
|
|
|
101
112
|
# Ensure default project is valid
|
|
102
113
|
if self.default_project not in self.projects: # pragma: no cover
|
|
@@ -124,6 +135,7 @@ class BasicMemoryConfig(BaseSettings):
|
|
|
124
135
|
"""
|
|
125
136
|
|
|
126
137
|
# Load the app-level database path from the global config
|
|
138
|
+
config_manager = ConfigManager()
|
|
127
139
|
config = config_manager.load_config() # pragma: no cover
|
|
128
140
|
return config.app_database_path # pragma: no cover
|
|
129
141
|
|
|
@@ -162,20 +174,21 @@ class ConfigManager:
|
|
|
162
174
|
# Ensure config directory exists
|
|
163
175
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
164
176
|
|
|
165
|
-
|
|
166
|
-
|
|
177
|
+
@property
|
|
178
|
+
def config(self) -> BasicMemoryConfig:
|
|
179
|
+
"""Get configuration, loading it lazily if needed."""
|
|
180
|
+
return self.load_config()
|
|
167
181
|
|
|
168
182
|
def load_config(self) -> BasicMemoryConfig:
|
|
169
183
|
"""Load configuration from file or create default."""
|
|
184
|
+
|
|
170
185
|
if self.config_file.exists():
|
|
171
186
|
try:
|
|
172
187
|
data = json.loads(self.config_file.read_text(encoding="utf-8"))
|
|
173
188
|
return BasicMemoryConfig(**data)
|
|
174
189
|
except Exception as e: # pragma: no cover
|
|
175
|
-
logger.
|
|
176
|
-
|
|
177
|
-
self.save_config(config)
|
|
178
|
-
return config
|
|
190
|
+
logger.exception(f"Failed to load config: {e}")
|
|
191
|
+
raise e
|
|
179
192
|
else:
|
|
180
193
|
config = BasicMemoryConfig()
|
|
181
194
|
self.save_config(config)
|
|
@@ -183,10 +196,7 @@ class ConfigManager:
|
|
|
183
196
|
|
|
184
197
|
def save_config(self, config: BasicMemoryConfig) -> None:
|
|
185
198
|
"""Save configuration to file."""
|
|
186
|
-
|
|
187
|
-
self.config_file.write_text(json.dumps(config.model_dump(), indent=2))
|
|
188
|
-
except Exception as e: # pragma: no cover
|
|
189
|
-
logger.error(f"Failed to save config: {e}")
|
|
199
|
+
save_basic_memory_config(self.config_file, config)
|
|
190
200
|
|
|
191
201
|
@property
|
|
192
202
|
def projects(self) -> Dict[str, str]:
|
|
@@ -208,8 +218,10 @@ class ConfigManager:
|
|
|
208
218
|
project_path = Path(path)
|
|
209
219
|
project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
|
|
210
220
|
|
|
211
|
-
|
|
212
|
-
self.
|
|
221
|
+
# Load config, modify it, and save it
|
|
222
|
+
config = self.load_config()
|
|
223
|
+
config.projects[name] = project_path.as_posix()
|
|
224
|
+
self.save_config(config)
|
|
213
225
|
return ProjectConfig(name=name, home=project_path)
|
|
214
226
|
|
|
215
227
|
def remove_project(self, name: str) -> None:
|
|
@@ -219,11 +231,13 @@ class ConfigManager:
|
|
|
219
231
|
if not project_name: # pragma: no cover
|
|
220
232
|
raise ValueError(f"Project '{name}' not found")
|
|
221
233
|
|
|
222
|
-
|
|
234
|
+
# Load config, check, modify, and save
|
|
235
|
+
config = self.load_config()
|
|
236
|
+
if project_name == config.default_project: # pragma: no cover
|
|
223
237
|
raise ValueError(f"Cannot remove the default project '{name}'")
|
|
224
238
|
|
|
225
|
-
del
|
|
226
|
-
self.save_config(
|
|
239
|
+
del config.projects[name]
|
|
240
|
+
self.save_config(config)
|
|
227
241
|
|
|
228
242
|
def set_default_project(self, name: str) -> None:
|
|
229
243
|
"""Set the default project."""
|
|
@@ -231,15 +245,18 @@ class ConfigManager:
|
|
|
231
245
|
if not project_name: # pragma: no cover
|
|
232
246
|
raise ValueError(f"Project '{name}' not found")
|
|
233
247
|
|
|
234
|
-
|
|
235
|
-
self.
|
|
248
|
+
# Load config, modify, and save
|
|
249
|
+
config = self.load_config()
|
|
250
|
+
config.default_project = project_name
|
|
251
|
+
self.save_config(config)
|
|
236
252
|
|
|
237
253
|
def get_project(self, name: str) -> Tuple[str, str] | Tuple[None, None]:
|
|
238
254
|
"""Look up a project from the configuration by name or permalink"""
|
|
239
255
|
project_permalink = generate_permalink(name)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
256
|
+
app_config = self.config
|
|
257
|
+
for project_name, path in app_config.projects.items():
|
|
258
|
+
if project_permalink == generate_permalink(project_name):
|
|
259
|
+
return project_name, path
|
|
243
260
|
return None, None
|
|
244
261
|
|
|
245
262
|
|
|
@@ -252,7 +269,7 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
|
|
252
269
|
actual_project_name = None
|
|
253
270
|
|
|
254
271
|
# load the config from file
|
|
255
|
-
|
|
272
|
+
config_manager = ConfigManager()
|
|
256
273
|
app_config = config_manager.load_config()
|
|
257
274
|
|
|
258
275
|
# Get project name from environment variable
|
|
@@ -282,14 +299,12 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
|
|
|
282
299
|
raise ValueError(f"Project '{actual_project_name}' not found") # pragma: no cover
|
|
283
300
|
|
|
284
301
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
# Load project config for the default project (backward compatibility)
|
|
292
|
-
config: ProjectConfig = get_project_config()
|
|
302
|
+
def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
|
|
303
|
+
"""Save configuration to file."""
|
|
304
|
+
try:
|
|
305
|
+
file_path.write_text(json.dumps(config.model_dump(), indent=2))
|
|
306
|
+
except Exception as e: # pragma: no cover
|
|
307
|
+
logger.error(f"Failed to save config: {e}")
|
|
293
308
|
|
|
294
309
|
|
|
295
310
|
def update_current_project(project_name: str) -> None:
|
|
@@ -341,12 +356,24 @@ def setup_basic_memory_logging(): # pragma: no cover
|
|
|
341
356
|
# print("Skipping duplicate logging setup")
|
|
342
357
|
return
|
|
343
358
|
|
|
359
|
+
# Check for console logging environment variable - accept more truthy values
|
|
360
|
+
console_logging_env = os.getenv("BASIC_MEMORY_CONSOLE_LOGGING", "false").lower()
|
|
361
|
+
console_logging = console_logging_env in ("true", "1", "yes", "on")
|
|
362
|
+
|
|
363
|
+
# Check for log level environment variable first, fall back to config
|
|
364
|
+
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL")
|
|
365
|
+
if not log_level:
|
|
366
|
+
config_manager = ConfigManager()
|
|
367
|
+
log_level = config_manager.config.log_level
|
|
368
|
+
|
|
369
|
+
config_manager = ConfigManager()
|
|
370
|
+
config = get_project_config()
|
|
344
371
|
setup_logging(
|
|
345
372
|
env=config_manager.config.env,
|
|
346
373
|
home_dir=user_home, # Use user home for logs
|
|
347
|
-
log_level=
|
|
374
|
+
log_level=log_level,
|
|
348
375
|
log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
|
|
349
|
-
console=
|
|
376
|
+
console=console_logging,
|
|
350
377
|
)
|
|
351
378
|
|
|
352
379
|
logger.info(f"Basic Memory {basic_memory.__version__} (Project: {config.project})")
|
basic_memory/db.py
CHANGED
|
@@ -4,7 +4,7 @@ from enum import Enum, auto
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import AsyncGenerator, Optional
|
|
6
6
|
|
|
7
|
-
from basic_memory.config import BasicMemoryConfig
|
|
7
|
+
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
|
8
8
|
from alembic import command
|
|
9
9
|
from alembic.config import Config
|
|
10
10
|
|
|
@@ -88,7 +88,6 @@ async def get_or_create_db(
|
|
|
88
88
|
db_path: Path,
|
|
89
89
|
db_type: DatabaseType = DatabaseType.FILESYSTEM,
|
|
90
90
|
ensure_migrations: bool = True,
|
|
91
|
-
app_config: Optional["BasicMemoryConfig"] = None,
|
|
92
91
|
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
|
93
92
|
"""Get or create database engine and session maker."""
|
|
94
93
|
global _engine, _session_maker
|
|
@@ -98,10 +97,7 @@ async def get_or_create_db(
|
|
|
98
97
|
|
|
99
98
|
# Run migrations automatically unless explicitly disabled
|
|
100
99
|
if ensure_migrations:
|
|
101
|
-
|
|
102
|
-
from basic_memory.config import app_config as global_app_config
|
|
103
|
-
|
|
104
|
-
app_config = global_app_config
|
|
100
|
+
app_config = ConfigManager().config
|
|
105
101
|
await run_migrations(app_config, db_type)
|
|
106
102
|
|
|
107
103
|
# These checks should never fail since we just created the engine and session maker
|
basic_memory/deps.py
CHANGED
|
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import (
|
|
|
12
12
|
import pathlib
|
|
13
13
|
|
|
14
14
|
from basic_memory import db
|
|
15
|
-
from basic_memory.config import ProjectConfig, BasicMemoryConfig
|
|
15
|
+
from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager
|
|
16
16
|
from basic_memory.importers import (
|
|
17
17
|
ChatGPTImporter,
|
|
18
18
|
ClaudeConversationsImporter,
|
|
@@ -33,10 +33,10 @@ from basic_memory.services.file_service import FileService
|
|
|
33
33
|
from basic_memory.services.link_resolver import LinkResolver
|
|
34
34
|
from basic_memory.services.search_service import SearchService
|
|
35
35
|
from basic_memory.sync import SyncService
|
|
36
|
-
from basic_memory.config import app_config
|
|
37
36
|
|
|
38
37
|
|
|
39
38
|
def get_app_config() -> BasicMemoryConfig: # pragma: no cover
|
|
39
|
+
app_config = ConfigManager().config
|
|
40
40
|
return app_config
|
|
41
41
|
|
|
42
42
|
|
|
@@ -297,6 +297,7 @@ ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
|
|
|
297
297
|
|
|
298
298
|
|
|
299
299
|
async def get_sync_service(
|
|
300
|
+
app_config: AppConfigDep,
|
|
300
301
|
entity_service: EntityServiceDep,
|
|
301
302
|
entity_parser: EntityParserDep,
|
|
302
303
|
entity_repository: EntityRepositoryDep,
|
basic_memory/file_utils.py
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
import re
|
|
5
6
|
from typing import Any, Dict, Union
|
|
6
7
|
|
|
7
8
|
import yaml
|
|
9
|
+
import frontmatter
|
|
8
10
|
from loguru import logger
|
|
9
11
|
|
|
10
12
|
from basic_memory.utils import FilePath
|
|
@@ -233,3 +235,66 @@ async def update_frontmatter(path: FilePath, updates: Dict[str, Any]) -> str:
|
|
|
233
235
|
error=str(e),
|
|
234
236
|
)
|
|
235
237
|
raise FileError(f"Failed to update frontmatter: {e}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def dump_frontmatter(post: frontmatter.Post) -> str:
|
|
241
|
+
"""
|
|
242
|
+
Serialize frontmatter.Post to markdown with Obsidian-compatible YAML format.
|
|
243
|
+
|
|
244
|
+
This function ensures that tags are formatted as YAML lists instead of JSON arrays:
|
|
245
|
+
|
|
246
|
+
Good (Obsidian compatible):
|
|
247
|
+
---
|
|
248
|
+
tags:
|
|
249
|
+
- system
|
|
250
|
+
- overview
|
|
251
|
+
- reference
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
Bad (current behavior):
|
|
255
|
+
---
|
|
256
|
+
tags: ["system", "overview", "reference"]
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
post: frontmatter.Post object to serialize
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
String containing markdown with properly formatted YAML frontmatter
|
|
264
|
+
"""
|
|
265
|
+
if not post.metadata:
|
|
266
|
+
# No frontmatter, just return content
|
|
267
|
+
return post.content
|
|
268
|
+
|
|
269
|
+
# Serialize YAML with block style for lists
|
|
270
|
+
yaml_str = yaml.dump(
|
|
271
|
+
post.metadata,
|
|
272
|
+
sort_keys=False,
|
|
273
|
+
allow_unicode=True,
|
|
274
|
+
default_flow_style=False
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Construct the final markdown with frontmatter
|
|
278
|
+
if post.content:
|
|
279
|
+
return f"---\n{yaml_str}---\n\n{post.content}"
|
|
280
|
+
else:
|
|
281
|
+
return f"---\n{yaml_str}---\n"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def sanitize_for_filename(text: str, replacement: str = "-") -> str:
|
|
285
|
+
"""
|
|
286
|
+
Sanitize string to be safe for use as a note title
|
|
287
|
+
Replaces path separators and other problematic characters
|
|
288
|
+
with hyphens.
|
|
289
|
+
"""
|
|
290
|
+
# replace both POSIX and Windows path separators
|
|
291
|
+
text = re.sub(r"[/\\]", replacement, text)
|
|
292
|
+
|
|
293
|
+
# replace some other problematic chars
|
|
294
|
+
text = re.sub(r'[<>:"|?*]', replacement, text)
|
|
295
|
+
|
|
296
|
+
# compress multiple, repeated replacements
|
|
297
|
+
text = re.sub(f"{re.escape(replacement)}+", replacement, text)
|
|
298
|
+
|
|
299
|
+
return text.strip(replacement)
|
|
300
|
+
|
|
@@ -93,7 +93,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
|
|
93
93
|
break
|
|
94
94
|
|
|
95
95
|
# Generate permalink
|
|
96
|
-
date_prefix = datetime.fromtimestamp(created_at).strftime("%Y%m%d")
|
|
96
|
+
date_prefix = datetime.fromtimestamp(created_at).astimezone().strftime("%Y%m%d")
|
|
97
97
|
clean_title = clean_filename(conversation["title"])
|
|
98
98
|
|
|
99
99
|
# Format content
|
|
@@ -193,7 +193,7 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
|
|
193
193
|
def _traverse_messages(
|
|
194
194
|
self, mapping: Dict[str, Any], root_id: Optional[str], seen: Set[str]
|
|
195
195
|
) -> List[Dict[str, Any]]: # pragma: no cover
|
|
196
|
-
"""Traverse message tree
|
|
196
|
+
"""Traverse message tree iteratively to handle deep conversations.
|
|
197
197
|
|
|
198
198
|
Args:
|
|
199
199
|
mapping: Message mapping.
|
|
@@ -204,19 +204,29 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
|
|
204
204
|
List of message data.
|
|
205
205
|
"""
|
|
206
206
|
messages = []
|
|
207
|
-
|
|
207
|
+
if not root_id:
|
|
208
|
+
return messages
|
|
208
209
|
|
|
209
|
-
|
|
210
|
+
# Use iterative approach with stack to avoid recursion depth issues
|
|
211
|
+
stack = [root_id]
|
|
212
|
+
|
|
213
|
+
while stack:
|
|
214
|
+
node_id = stack.pop()
|
|
215
|
+
if not node_id:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
node = mapping.get(node_id)
|
|
219
|
+
if not node:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Process current node if it has a message and hasn't been seen
|
|
210
223
|
if node["id"] not in seen and node.get("message"):
|
|
211
224
|
seen.add(node["id"])
|
|
212
225
|
messages.append(node["message"])
|
|
213
226
|
|
|
214
|
-
#
|
|
227
|
+
# Add children to stack in reverse order to maintain conversation flow
|
|
215
228
|
children = node.get("children", [])
|
|
216
|
-
for child_id in children:
|
|
217
|
-
|
|
218
|
-
messages.extend(child_msgs)
|
|
219
|
-
|
|
220
|
-
break # Don't follow siblings
|
|
229
|
+
for child_id in reversed(children):
|
|
230
|
+
stack.append(child_id)
|
|
221
231
|
|
|
222
232
|
return messages
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
from typing import Any, Dict, List
|
|
5
5
|
|
|
6
|
-
from basic_memory.config import
|
|
6
|
+
from basic_memory.config import get_project_config
|
|
7
7
|
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown, Observation, Relation
|
|
8
8
|
from basic_memory.importers.base import Importer
|
|
9
9
|
from basic_memory.schemas.importer import EntityImportResult
|
|
@@ -27,10 +27,12 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
|
|
27
27
|
Returns:
|
|
28
28
|
EntityImportResult containing statistics and status of the import.
|
|
29
29
|
"""
|
|
30
|
+
config = get_project_config()
|
|
30
31
|
try:
|
|
31
32
|
# First pass - collect all relations by source entity
|
|
32
33
|
entity_relations: Dict[str, List[Relation]] = {}
|
|
33
34
|
entities: Dict[str, Dict[str, Any]] = {}
|
|
35
|
+
skipped_entities: int = 0
|
|
34
36
|
|
|
35
37
|
# Ensure the base path exists
|
|
36
38
|
base_path = config.home # pragma: no cover
|
|
@@ -41,7 +43,13 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
|
|
41
43
|
for line in source_data:
|
|
42
44
|
data = line
|
|
43
45
|
if data["type"] == "entity":
|
|
44
|
-
|
|
46
|
+
# Handle different possible name keys
|
|
47
|
+
entity_name = data.get("name") or data.get("entityName") or data.get("id")
|
|
48
|
+
if not entity_name:
|
|
49
|
+
logger.warning(f"Entity missing name field: {data}")
|
|
50
|
+
skipped_entities += 1
|
|
51
|
+
continue
|
|
52
|
+
entities[entity_name] = data
|
|
45
53
|
elif data["type"] == "relation":
|
|
46
54
|
# Store relation with its source entity
|
|
47
55
|
source = data.get("from") or data.get("from_id")
|
|
@@ -57,25 +65,31 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
|
|
57
65
|
# Second pass - create and write entities
|
|
58
66
|
entities_created = 0
|
|
59
67
|
for name, entity_data in entities.items():
|
|
68
|
+
# Get entity type with fallback
|
|
69
|
+
entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
|
|
70
|
+
|
|
60
71
|
# Ensure entity type directory exists
|
|
61
|
-
entity_type_dir = base_path /
|
|
72
|
+
entity_type_dir = base_path / entity_type
|
|
62
73
|
entity_type_dir.mkdir(parents=True, exist_ok=True)
|
|
63
74
|
|
|
75
|
+
# Get observations with fallback to empty list
|
|
76
|
+
observations = entity_data.get("observations", [])
|
|
77
|
+
|
|
64
78
|
entity = EntityMarkdown(
|
|
65
79
|
frontmatter=EntityFrontmatter(
|
|
66
80
|
metadata={
|
|
67
|
-
"type":
|
|
81
|
+
"type": entity_type,
|
|
68
82
|
"title": name,
|
|
69
|
-
"permalink": f"{
|
|
83
|
+
"permalink": f"{entity_type}/{name}",
|
|
70
84
|
}
|
|
71
85
|
),
|
|
72
86
|
content=f"# {name}\n",
|
|
73
|
-
observations=[Observation(content=obs) for obs in
|
|
87
|
+
observations=[Observation(content=obs) for obs in observations],
|
|
74
88
|
relations=entity_relations.get(name, []),
|
|
75
89
|
)
|
|
76
90
|
|
|
77
91
|
# Write entity file
|
|
78
|
-
file_path = base_path / f"{
|
|
92
|
+
file_path = base_path / f"{entity_type}/{name}.md"
|
|
79
93
|
await self.write_entity(entity, file_path)
|
|
80
94
|
entities_created += 1
|
|
81
95
|
|
|
@@ -86,6 +100,7 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
|
|
86
100
|
success=True,
|
|
87
101
|
entities=entities_created,
|
|
88
102
|
relations=relations_count,
|
|
103
|
+
skipped_entities=skipped_entities,
|
|
89
104
|
)
|
|
90
105
|
|
|
91
106
|
except Exception as e: # pragma: no cover
|
basic_memory/importers/utils.py
CHANGED
|
@@ -43,13 +43,13 @@ def format_timestamp(timestamp: Any) -> str: # pragma: no cover
|
|
|
43
43
|
except ValueError:
|
|
44
44
|
try:
|
|
45
45
|
# Try unix timestamp as string
|
|
46
|
-
timestamp = datetime.fromtimestamp(float(timestamp))
|
|
46
|
+
timestamp = datetime.fromtimestamp(float(timestamp)).astimezone()
|
|
47
47
|
except ValueError:
|
|
48
48
|
# Return as is if we can't parse it
|
|
49
49
|
return timestamp
|
|
50
50
|
elif isinstance(timestamp, (int, float)):
|
|
51
51
|
# Unix timestamp
|
|
52
|
-
timestamp = datetime.fromtimestamp(timestamp)
|
|
52
|
+
timestamp = datetime.fromtimestamp(timestamp).astimezone()
|
|
53
53
|
|
|
54
54
|
if isinstance(timestamp, datetime):
|
|
55
55
|
return timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
|
@@ -130,6 +130,6 @@ class EntityParser:
|
|
|
130
130
|
content=post.content,
|
|
131
131
|
observations=entity_content.observations,
|
|
132
132
|
relations=entity_content.relations,
|
|
133
|
-
created=datetime.fromtimestamp(file_stats.st_ctime),
|
|
134
|
-
modified=datetime.fromtimestamp(file_stats.st_mtime),
|
|
133
|
+
created=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
|
|
134
|
+
modified=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
|
|
135
135
|
)
|
|
@@ -2,11 +2,11 @@ from pathlib import Path
|
|
|
2
2
|
from typing import Optional
|
|
3
3
|
from collections import OrderedDict
|
|
4
4
|
|
|
5
|
-
import frontmatter
|
|
6
5
|
from frontmatter import Post
|
|
7
6
|
from loguru import logger
|
|
8
7
|
|
|
9
8
|
from basic_memory import file_utils
|
|
9
|
+
from basic_memory.file_utils import dump_frontmatter
|
|
10
10
|
from basic_memory.markdown.entity_parser import EntityParser
|
|
11
11
|
from basic_memory.markdown.schemas import EntityMarkdown, Observation, Relation
|
|
12
12
|
|
|
@@ -115,7 +115,7 @@ class MarkdownProcessor:
|
|
|
115
115
|
|
|
116
116
|
# Create Post object for frontmatter
|
|
117
117
|
post = Post(content, **frontmatter_dict)
|
|
118
|
-
final_content =
|
|
118
|
+
final_content = dump_frontmatter(post)
|
|
119
119
|
|
|
120
120
|
logger.debug(f"writing file {path} with content:\n{final_content}")
|
|
121
121
|
|
basic_memory/markdown/plugins.py
CHANGED
|
@@ -8,35 +8,49 @@ from markdown_it.token import Token
|
|
|
8
8
|
# Observation handling functions
|
|
9
9
|
def is_observation(token: Token) -> bool:
|
|
10
10
|
"""Check if token looks like our observation format."""
|
|
11
|
+
import re
|
|
11
12
|
if token.type != "inline": # pragma: no cover
|
|
12
13
|
return False
|
|
13
|
-
|
|
14
|
-
content = token.content.strip()
|
|
14
|
+
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
15
|
+
content = (token.tag or token.content).strip()
|
|
15
16
|
if not content: # pragma: no cover
|
|
16
17
|
return False
|
|
17
|
-
|
|
18
18
|
# if it's a markdown_task, return false
|
|
19
19
|
if content.startswith("[ ]") or content.startswith("[x]") or content.startswith("[-]"):
|
|
20
20
|
return False
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
|
|
22
|
+
# Exclude markdown links: [text](url)
|
|
23
|
+
if re.match(r"^\[.*?\]\(.*?\)$", content):
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
# Exclude wiki links: [[text]]
|
|
27
|
+
if re.match(r"^\[\[.*?\]\]$", content):
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
# Check for proper observation format: [category] content
|
|
31
|
+
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
|
|
23
32
|
has_tags = "#" in content
|
|
24
|
-
return
|
|
33
|
+
return bool(match) or has_tags
|
|
25
34
|
|
|
26
35
|
|
|
27
36
|
def parse_observation(token: Token) -> Dict[str, Any]:
|
|
28
37
|
"""Extract observation parts from token."""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
import re
|
|
39
|
+
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
40
|
+
content = (token.tag or token.content).strip()
|
|
41
|
+
|
|
42
|
+
# Parse [category] with regex
|
|
43
|
+
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
|
|
33
44
|
category = None
|
|
34
|
-
if
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
if match:
|
|
46
|
+
category = match.group(1).strip()
|
|
47
|
+
content = match.group(2).strip()
|
|
48
|
+
else:
|
|
49
|
+
# Handle empty brackets [] followed by content
|
|
50
|
+
empty_match = re.match(r"^\[\]\s+(.+)", content)
|
|
51
|
+
if empty_match:
|
|
52
|
+
content = empty_match.group(1).strip()
|
|
53
|
+
|
|
40
54
|
# Parse (context)
|
|
41
55
|
context = None
|
|
42
56
|
if content.endswith(")"):
|
|
@@ -44,20 +58,18 @@ def parse_observation(token: Token) -> Dict[str, Any]:
|
|
|
44
58
|
if start != -1:
|
|
45
59
|
context = content[start + 1 : -1].strip()
|
|
46
60
|
content = content[:start].strip()
|
|
47
|
-
|
|
61
|
+
|
|
48
62
|
# Extract tags and keep original content
|
|
49
63
|
tags = []
|
|
50
64
|
parts = content.split()
|
|
51
65
|
for part in parts:
|
|
52
66
|
if part.startswith("#"):
|
|
53
|
-
# Handle multiple #tags stuck together
|
|
54
67
|
if "#" in part[1:]:
|
|
55
|
-
# Split on # but keep non-empty tags
|
|
56
68
|
subtags = [t for t in part.split("#") if t]
|
|
57
69
|
tags.extend(subtags)
|
|
58
70
|
else:
|
|
59
71
|
tags.append(part[1:])
|
|
60
|
-
|
|
72
|
+
|
|
61
73
|
return {
|
|
62
74
|
"category": category,
|
|
63
75
|
"content": content,
|
|
@@ -72,14 +84,16 @@ def is_explicit_relation(token: Token) -> bool:
|
|
|
72
84
|
if token.type != "inline": # pragma: no cover
|
|
73
85
|
return False
|
|
74
86
|
|
|
75
|
-
|
|
87
|
+
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
88
|
+
content = (token.tag or token.content).strip()
|
|
76
89
|
return "[[" in content and "]]" in content
|
|
77
90
|
|
|
78
91
|
|
|
79
92
|
def parse_relation(token: Token) -> Dict[str, Any] | None:
|
|
80
93
|
"""Extract relation parts from token."""
|
|
81
94
|
# Remove bullet point if present
|
|
82
|
-
|
|
95
|
+
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
96
|
+
content = (token.tag or token.content).strip()
|
|
83
97
|
|
|
84
98
|
# Extract [[target]]
|
|
85
99
|
target = None
|
|
@@ -213,10 +227,12 @@ def relation_plugin(md: MarkdownIt) -> None:
|
|
|
213
227
|
token.meta["relations"] = [rel]
|
|
214
228
|
|
|
215
229
|
# Always check for inline links in any text
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if
|
|
219
|
-
|
|
230
|
+
else:
|
|
231
|
+
content = token.tag or token.content
|
|
232
|
+
if "[[" in content:
|
|
233
|
+
rels = parse_inline_relations(content)
|
|
234
|
+
if rels:
|
|
235
|
+
token.meta["relations"] = token.meta.get("relations", []) + rels
|
|
220
236
|
|
|
221
237
|
# Add the rule after inline processing
|
|
222
238
|
md.core.ruler.after("inline", "relations", relation_rule)
|
basic_memory/markdown/utils.py
CHANGED
|
@@ -41,7 +41,7 @@ def entity_model_from_markdown(
|
|
|
41
41
|
# Only update permalink if it exists in frontmatter, otherwise preserve existing
|
|
42
42
|
if markdown.frontmatter.permalink is not None:
|
|
43
43
|
model.permalink = markdown.frontmatter.permalink
|
|
44
|
-
model.file_path =
|
|
44
|
+
model.file_path = file_path.as_posix()
|
|
45
45
|
model.content_type = "text/markdown"
|
|
46
46
|
model.created_at = markdown.created
|
|
47
47
|
model.updated_at = markdown.modified
|