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.

Files changed (69) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +3 -1
  3. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +53 -0
  4. basic_memory/api/app.py +4 -1
  5. basic_memory/api/routers/management_router.py +3 -1
  6. basic_memory/api/routers/project_router.py +21 -13
  7. basic_memory/api/routers/resource_router.py +3 -3
  8. basic_memory/cli/app.py +3 -3
  9. basic_memory/cli/commands/__init__.py +1 -2
  10. basic_memory/cli/commands/db.py +5 -5
  11. basic_memory/cli/commands/import_chatgpt.py +3 -2
  12. basic_memory/cli/commands/import_claude_conversations.py +3 -1
  13. basic_memory/cli/commands/import_claude_projects.py +3 -1
  14. basic_memory/cli/commands/import_memory_json.py +5 -2
  15. basic_memory/cli/commands/mcp.py +3 -15
  16. basic_memory/cli/commands/project.py +46 -6
  17. basic_memory/cli/commands/status.py +4 -1
  18. basic_memory/cli/commands/sync.py +10 -2
  19. basic_memory/cli/main.py +0 -1
  20. basic_memory/config.py +61 -34
  21. basic_memory/db.py +2 -6
  22. basic_memory/deps.py +3 -2
  23. basic_memory/file_utils.py +65 -0
  24. basic_memory/importers/chatgpt_importer.py +20 -10
  25. basic_memory/importers/memory_json_importer.py +22 -7
  26. basic_memory/importers/utils.py +2 -2
  27. basic_memory/markdown/entity_parser.py +2 -2
  28. basic_memory/markdown/markdown_processor.py +2 -2
  29. basic_memory/markdown/plugins.py +42 -26
  30. basic_memory/markdown/utils.py +1 -1
  31. basic_memory/mcp/async_client.py +22 -2
  32. basic_memory/mcp/project_session.py +6 -4
  33. basic_memory/mcp/prompts/__init__.py +0 -2
  34. basic_memory/mcp/server.py +8 -71
  35. basic_memory/mcp/tools/build_context.py +12 -2
  36. basic_memory/mcp/tools/move_note.py +24 -12
  37. basic_memory/mcp/tools/project_management.py +22 -7
  38. basic_memory/mcp/tools/read_content.py +16 -0
  39. basic_memory/mcp/tools/read_note.py +17 -2
  40. basic_memory/mcp/tools/sync_status.py +3 -2
  41. basic_memory/mcp/tools/write_note.py +9 -1
  42. basic_memory/models/knowledge.py +13 -2
  43. basic_memory/models/project.py +3 -3
  44. basic_memory/repository/entity_repository.py +2 -2
  45. basic_memory/repository/project_repository.py +19 -1
  46. basic_memory/repository/search_repository.py +7 -3
  47. basic_memory/schemas/base.py +40 -10
  48. basic_memory/schemas/importer.py +1 -0
  49. basic_memory/schemas/memory.py +23 -11
  50. basic_memory/services/context_service.py +12 -2
  51. basic_memory/services/directory_service.py +7 -0
  52. basic_memory/services/entity_service.py +56 -10
  53. basic_memory/services/initialization.py +0 -75
  54. basic_memory/services/project_service.py +93 -36
  55. basic_memory/sync/background_sync.py +4 -3
  56. basic_memory/sync/sync_service.py +53 -4
  57. basic_memory/sync/watch_service.py +31 -8
  58. basic_memory/utils.py +234 -71
  59. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/METADATA +21 -92
  60. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/RECORD +63 -68
  61. basic_memory/cli/commands/auth.py +0 -136
  62. basic_memory/mcp/auth_provider.py +0 -270
  63. basic_memory/mcp/external_auth_provider.py +0 -321
  64. basic_memory/mcp/prompts/sync_status.py +0 -112
  65. basic_memory/mcp/supabase_auth_provider.py +0 -463
  66. basic_memory/services/migration_service.py +0 -168
  67. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/WHEEL +0 -0
  68. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/entry_points.txt +0 -0
  69. {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": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
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"] = str(
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
- # Load or create configuration
166
- self.config = self.load_config()
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.error(f"Failed to load config: {e}")
176
- config = BasicMemoryConfig()
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
- try:
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
- self.config.projects[name] = str(project_path)
212
- self.save_config(self.config)
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
- if project_name == self.config.default_project: # pragma: no cover
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 self.config.projects[name]
226
- self.save_config(self.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
- self.config.default_project = name
235
- self.save_config(self.config)
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
- for name, path in app_config.projects.items():
241
- if project_permalink == generate_permalink(name):
242
- return name, path
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
- global app_config
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
- # Create config manager
286
- config_manager = ConfigManager()
287
-
288
- # Export the app-level config
289
- app_config: BasicMemoryConfig = config_manager.config
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=config_manager.load_config().log_level,
374
+ log_level=log_level,
348
375
  log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
349
- console=False,
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
- if app_config is None:
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,
@@ -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 and return messages in order.
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
- node = mapping.get(root_id) if root_id else None
207
+ if not root_id:
208
+ return messages
208
209
 
209
- while node:
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
- # Follow children
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
- child_msgs = self._traverse_messages(mapping, child_id, seen)
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 config
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
- entities[data["name"]] = data
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 / entity_data["entityType"]
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": entity_data["entityType"],
81
+ "type": entity_type,
68
82
  "title": name,
69
- "permalink": f"{entity_data['entityType']}/{name}",
83
+ "permalink": f"{entity_type}/{name}",
70
84
  }
71
85
  ),
72
86
  content=f"# {name}\n",
73
- observations=[Observation(content=obs) for obs in entity_data["observations"]],
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"{entity_data['entityType']}/{name}.md"
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
@@ -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 = frontmatter.dumps(post, sort_keys=False)
118
+ final_content = dump_frontmatter(post)
119
119
 
120
120
  logger.debug(f"writing file {path} with content:\n{final_content}")
121
121
 
@@ -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
- has_category = content.startswith("[") and "]" in content
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 has_category or has_tags
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
- # Strip bullet point if present
30
- content = token.content.strip()
31
-
32
- # Parse [category]
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 content.startswith("["):
35
- end = content.find("]")
36
- if end != -1:
37
- category = content[1:end].strip() or None # Convert empty to None
38
- content = content[end + 1 :].strip()
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
- content = token.content.strip()
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
- content = token.content.strip()
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
- elif "[[" in token.content:
217
- rels = parse_inline_relations(token.content)
218
- if rels:
219
- token.meta["relations"] = token.meta.get("relations", []) + rels
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)
@@ -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 = str(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