basic-memory 0.7.0__py3-none-any.whl → 0.16.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 (150) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +64 -18
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +166 -21
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +9 -64
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +119 -4
  23. basic_memory/api/routers/search_router.py +5 -5
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +43 -9
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +28 -12
  41. basic_memory/cli/commands/import_chatgpt.py +40 -220
  42. basic_memory/cli/commands/import_claude_conversations.py +41 -168
  43. basic_memory/cli/commands/import_claude_projects.py +46 -157
  44. basic_memory/cli/commands/import_memory_json.py +48 -108
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +50 -33
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +8 -7
  50. basic_memory/config.py +477 -23
  51. basic_memory/db.py +168 -17
  52. basic_memory/deps.py +251 -25
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -23
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +411 -62
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +187 -25
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +56 -2
  115. basic_memory/schemas/response.py +1 -1
  116. basic_memory/schemas/search.py +31 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +241 -104
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +590 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +49 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +168 -32
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1180 -109
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +383 -51
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -206
  140. basic_memory/cli/commands/tools.py +0 -157
  141. basic_memory/mcp/tools/knowledge.py +0 -68
  142. basic_memory/mcp/tools/memory.py +0 -170
  143. basic_memory/mcp/tools/notes.py +0 -202
  144. basic_memory/schemas/discovery.py +0 -28
  145. basic_memory/sync/file_change_scanner.py +0 -158
  146. basic_memory/sync/utils.py +0 -31
  147. basic_memory-0.7.0.dist-info/METADATA +0 -378
  148. basic_memory-0.7.0.dist-info/RECORD +0 -82
  149. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  150. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,58 @@
1
+ """Utility functions for import services."""
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+
8
+ def clean_filename(name: str) -> str: # pragma: no cover
9
+ """Clean a string to be used as a filename.
10
+
11
+ Args:
12
+ name: The string to clean.
13
+
14
+ Returns:
15
+ A cleaned string suitable for use as a filename.
16
+ """
17
+ # Replace common punctuation and whitespace with underscores
18
+ name = re.sub(r"[\s\-,.:/\\\[\]\(\)]+", "_", name)
19
+ # Remove any non-alphanumeric or underscore characters
20
+ name = re.sub(r"[^\w]+", "", name)
21
+ # Ensure the name isn't too long
22
+ if len(name) > 100: # pragma: no cover
23
+ name = name[:100]
24
+ # Ensure the name isn't empty
25
+ if not name: # pragma: no cover
26
+ name = "untitled"
27
+ return name
28
+
29
+
30
+ def format_timestamp(timestamp: Any) -> str: # pragma: no cover
31
+ """Format a timestamp for use in a filename or title.
32
+
33
+ Args:
34
+ timestamp: A timestamp in various formats.
35
+
36
+ Returns:
37
+ A formatted string representation of the timestamp.
38
+ """
39
+ if isinstance(timestamp, str):
40
+ try:
41
+ # Try ISO format
42
+ timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
43
+ except ValueError:
44
+ try:
45
+ # Try unix timestamp as string
46
+ timestamp = datetime.fromtimestamp(float(timestamp)).astimezone()
47
+ except ValueError:
48
+ # Return as is if we can't parse it
49
+ return timestamp
50
+ elif isinstance(timestamp, (int, float)):
51
+ # Unix timestamp
52
+ timestamp = datetime.fromtimestamp(timestamp).astimezone()
53
+
54
+ if isinstance(timestamp, datetime):
55
+ return timestamp.strftime("%Y-%m-%d %H:%M:%S")
56
+
57
+ # Return as is if we can't format it
58
+ return str(timestamp) # pragma: no cover
@@ -4,25 +4,104 @@ Uses markdown-it with plugins to parse structured data from markdown content.
4
4
  """
5
5
 
6
6
  from dataclasses import dataclass, field
7
+ from datetime import date, datetime
7
8
  from pathlib import Path
8
- from datetime import datetime
9
9
  from typing import Any, Optional
10
- import dateparser
11
10
 
12
- from markdown_it import MarkdownIt
11
+ import dateparser
13
12
  import frontmatter
13
+ import yaml
14
+ from loguru import logger
15
+ from markdown_it import MarkdownIt
14
16
 
15
17
  from basic_memory.markdown.plugins import observation_plugin, relation_plugin
16
18
  from basic_memory.markdown.schemas import (
17
- EntityMarkdown,
18
19
  EntityFrontmatter,
20
+ EntityMarkdown,
19
21
  Observation,
20
22
  Relation,
21
23
  )
24
+ from basic_memory.utils import parse_tags
22
25
 
23
26
  md = MarkdownIt().use(observation_plugin).use(relation_plugin)
24
27
 
25
28
 
29
+ def normalize_frontmatter_value(value: Any) -> Any:
30
+ """Normalize frontmatter values to safe types for processing.
31
+
32
+ PyYAML automatically converts various string-like values into native Python types:
33
+ - Date strings ("2025-10-24") → datetime.date objects
34
+ - Numbers ("1.0") → int or float
35
+ - Booleans ("true") → bool
36
+ - Lists → list objects
37
+
38
+ This can cause AttributeError when code expects strings and calls string methods
39
+ like .strip() on these values (see GitHub issue #236).
40
+
41
+ This function normalizes all frontmatter values to safe types:
42
+ - Dates/datetimes → ISO format strings
43
+ - Numbers (int/float) → strings
44
+ - Booleans → strings ("True"/"False")
45
+ - Lists → preserved as lists, but items are recursively normalized
46
+ - Dicts → preserved as dicts, but values are recursively normalized
47
+ - Strings → kept as-is
48
+ - None → kept as None
49
+
50
+ Args:
51
+ value: The frontmatter value to normalize
52
+
53
+ Returns:
54
+ The normalized value safe for string operations
55
+
56
+ Example:
57
+ >>> normalize_frontmatter_value(datetime.date(2025, 10, 24))
58
+ '2025-10-24'
59
+ >>> normalize_frontmatter_value([datetime.date(2025, 10, 24), "tag", 123])
60
+ ['2025-10-24', 'tag', '123']
61
+ >>> normalize_frontmatter_value(True)
62
+ 'True'
63
+ """
64
+ # Convert date/datetime objects to ISO format strings
65
+ if isinstance(value, datetime):
66
+ return value.isoformat()
67
+ if isinstance(value, date):
68
+ return value.isoformat()
69
+
70
+ # Convert boolean to string (must come before int check since bool is subclass of int)
71
+ if isinstance(value, bool):
72
+ return str(value)
73
+
74
+ # Convert numbers to strings
75
+ if isinstance(value, (int, float)):
76
+ return str(value)
77
+
78
+ # Recursively process lists (preserve as list, normalize items)
79
+ if isinstance(value, list):
80
+ return [normalize_frontmatter_value(item) for item in value]
81
+
82
+ # Recursively process dicts (preserve as dict, normalize values)
83
+ if isinstance(value, dict):
84
+ return {key: normalize_frontmatter_value(val) for key, val in value.items()}
85
+
86
+ # Keep strings and None as-is
87
+ return value
88
+
89
+
90
+ def normalize_frontmatter_metadata(metadata: dict) -> dict:
91
+ """Normalize all values in frontmatter metadata dict.
92
+
93
+ Converts date/datetime objects to ISO format strings to prevent
94
+ AttributeError when code expects strings (GitHub issue #236).
95
+
96
+ Args:
97
+ metadata: The frontmatter metadata dictionary
98
+
99
+ Returns:
100
+ A new dictionary with all values normalized
101
+ """
102
+ return {key: normalize_frontmatter_value(value) for key, value in metadata.items()}
103
+
104
+
26
105
  @dataclass
27
106
  class EntityContent:
28
107
  content: str
@@ -56,11 +135,11 @@ def parse(content: str) -> EntityContent:
56
135
  )
57
136
 
58
137
 
59
- def parse_tags(tags: Any) -> list[str]:
60
- """Parse tags into list of strings."""
61
- if isinstance(tags, (list, tuple)):
62
- return [str(t).strip() for t in tags if str(t).strip()]
63
- return [t.strip() for t in tags.split(",") if t.strip()]
138
+ # def parse_tags(tags: Any) -> list[str]:
139
+ # """Parse tags into list of strings."""
140
+ # if isinstance(tags, (list, tuple)):
141
+ # return [str(t).strip() for t in tags if str(t).strip()]
142
+ # return [t.strip() for t in tags.split(",") if t.strip()]
64
143
 
65
144
 
66
145
  class EntityParser:
@@ -88,33 +167,74 @@ class EntityParser:
88
167
  return parsed
89
168
  return None
90
169
 
91
- async def parse_file(self, file_path: Path) -> EntityMarkdown:
170
+ async def parse_file(self, path: Path | str) -> EntityMarkdown:
92
171
  """Parse markdown file into EntityMarkdown."""
93
172
 
94
- absolute_path = self.base_path / file_path
173
+ # Check if the path is already absolute
174
+ if (
175
+ isinstance(path, Path)
176
+ and path.is_absolute()
177
+ or (isinstance(path, str) and Path(path).is_absolute())
178
+ ):
179
+ absolute_path = Path(path)
180
+ else:
181
+ absolute_path = self.get_file_path(path)
182
+
95
183
  # Parse frontmatter and content using python-frontmatter
96
- post = frontmatter.load(str(absolute_path))
184
+ file_content = absolute_path.read_text(encoding="utf-8")
185
+ return await self.parse_file_content(absolute_path, file_content)
186
+
187
+ def get_file_path(self, path):
188
+ """Get absolute path for a file using the base path for the project."""
189
+ return self.base_path / path
190
+
191
+ async def parse_file_content(self, absolute_path, file_content):
192
+ # Parse frontmatter with proper error handling for malformed YAML (issue #185)
193
+ try:
194
+ post = frontmatter.loads(file_content)
195
+ except yaml.YAMLError as e:
196
+ # Log the YAML parsing error with file context
197
+ logger.warning(
198
+ f"Failed to parse YAML frontmatter in {absolute_path}: {e}. "
199
+ f"Treating file as plain markdown without frontmatter."
200
+ )
201
+ # Create a post with no frontmatter - treat entire content as markdown
202
+ post = frontmatter.Post(file_content, metadata={})
97
203
 
98
204
  # Extract file stat info
99
205
  file_stats = absolute_path.stat()
100
206
 
101
- metadata = post.metadata
102
- metadata["title"] = post.metadata.get("title", file_path.name)
103
- metadata["type"] = post.metadata.get("type", "note")
104
- metadata["tags"] = parse_tags(post.metadata.get("tags", []))
105
-
106
- # frontmatter
207
+ # Normalize frontmatter values to prevent AttributeError on date objects (issue #236)
208
+ # PyYAML automatically converts date strings like "2025-10-24" to datetime.date objects
209
+ # This normalization converts them back to ISO format strings to ensure compatibility
210
+ # with code that expects string values
211
+ metadata = normalize_frontmatter_metadata(post.metadata)
212
+
213
+ # Ensure required fields have defaults (issue #184, #387)
214
+ # Handle title - use default if missing, None/null, empty, or string "None"
215
+ title = metadata.get("title")
216
+ if not title or title == "None":
217
+ metadata["title"] = absolute_path.stem
218
+ else:
219
+ metadata["title"] = title
220
+ # Handle type - use default if missing OR explicitly set to None/null
221
+ entity_type = metadata.get("type")
222
+ metadata["type"] = entity_type if entity_type is not None else "note"
223
+
224
+ tags = parse_tags(metadata.get("tags", [])) # pyright: ignore
225
+ if tags:
226
+ metadata["tags"] = tags
227
+
228
+ # frontmatter - use metadata with defaults applied
107
229
  entity_frontmatter = EntityFrontmatter(
108
- metadata=post.metadata,
230
+ metadata=metadata,
109
231
  )
110
-
111
232
  entity_content = parse(post.content)
112
-
113
233
  return EntityMarkdown(
114
234
  frontmatter=entity_frontmatter,
115
235
  content=post.content,
116
236
  observations=entity_content.observations,
117
237
  relations=entity_content.relations,
118
- created=datetime.fromtimestamp(file_stats.st_ctime),
119
- modified=datetime.fromtimestamp(file_stats.st_mtime),
238
+ created=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
239
+ modified=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
120
240
  )
@@ -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
 
@@ -83,7 +83,7 @@ class MarkdownProcessor:
83
83
  """
84
84
  # Dirty check if needed
85
85
  if expected_checksum is not None:
86
- current_content = path.read_text()
86
+ current_content = path.read_text(encoding="utf-8")
87
87
  current_checksum = await file_utils.compute_checksum(current_content)
88
88
  if current_checksum != expected_checksum:
89
89
  raise DirtyFileError(f"File {path} has been modified")
@@ -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,34 +8,50 @@ 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
12
+
11
13
  if token.type != "inline": # pragma: no cover
12
14
  return False
13
-
14
- content = token.content.strip()
15
+ # Use token.tag which contains the actual content for test tokens, fallback to content
16
+ content = (token.tag or token.content).strip()
15
17
  if not content: # pragma: no cover
16
18
  return False
17
-
18
19
  # if it's a markdown_task, return false
19
20
  if content.startswith("[ ]") or content.startswith("[x]") or content.startswith("[-]"):
20
21
  return False
21
22
 
22
- has_category = content.startswith("[") and "]" in content
23
+ # Exclude markdown links: [text](url)
24
+ if re.match(r"^\[.*?\]\(.*?\)$", content):
25
+ return False
26
+
27
+ # Exclude wiki links: [[text]]
28
+ if re.match(r"^\[\[.*?\]\]$", content):
29
+ return False
30
+
31
+ # Check for proper observation format: [category] content
32
+ match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
23
33
  has_tags = "#" in content
24
- return has_category or has_tags
34
+ return bool(match) or has_tags
25
35
 
26
36
 
27
37
  def parse_observation(token: Token) -> Dict[str, Any]:
28
38
  """Extract observation parts from token."""
29
- # Strip bullet point if present
30
- content = token.content.strip()
39
+ import re
40
+
41
+ # Use token.tag which contains the actual content for test tokens, fallback to content
42
+ content = (token.tag or token.content).strip()
31
43
 
32
- # Parse [category]
44
+ # Parse [category] with regex
45
+ match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
33
46
  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()
47
+ if match:
48
+ category = match.group(1).strip()
49
+ content = match.group(2).strip()
50
+ else:
51
+ # Handle empty brackets [] followed by content
52
+ empty_match = re.match(r"^\[\]\s+(.+)", content)
53
+ if empty_match:
54
+ content = empty_match.group(1).strip()
39
55
 
40
56
  # Parse (context)
41
57
  context = None
@@ -50,9 +66,7 @@ def parse_observation(token: Token) -> Dict[str, Any]:
50
66
  parts = content.split()
51
67
  for part in parts:
52
68
  if part.startswith("#"):
53
- # Handle multiple #tags stuck together
54
69
  if "#" in part[1:]:
55
- # Split on # but keep non-empty tags
56
70
  subtags = [t for t in part.split("#") if t]
57
71
  tags.extend(subtags)
58
72
  else:
@@ -72,14 +86,16 @@ def is_explicit_relation(token: Token) -> bool:
72
86
  if token.type != "inline": # pragma: no cover
73
87
  return False
74
88
 
75
- content = token.content.strip()
89
+ # Use token.tag which contains the actual content for test tokens, fallback to content
90
+ content = (token.tag or token.content).strip()
76
91
  return "[[" in content and "]]" in content
77
92
 
78
93
 
79
94
  def parse_relation(token: Token) -> Dict[str, Any] | None:
80
95
  """Extract relation parts from token."""
81
96
  # Remove bullet point if present
82
- content = token.content.strip()
97
+ # Use token.tag which contains the actual content for test tokens, fallback to content
98
+ content = (token.tag or token.content).strip()
83
99
 
84
100
  # Extract [[target]]
85
101
  target = None
@@ -213,10 +229,12 @@ def relation_plugin(md: MarkdownIt) -> None:
213
229
  token.meta["relations"] = [rel]
214
230
 
215
231
  # 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
232
+ else:
233
+ content = token.tag or token.content
234
+ if "[[" in content:
235
+ rels = parse_inline_relations(content)
236
+ if rels:
237
+ token.meta["relations"] = token.meta.get("relations", []) + rels
220
238
 
221
239
  # Add the rule after inline processing
222
240
  md.core.ruler.after("inline", "relations", relation_rule)
@@ -42,7 +42,7 @@ class EntityFrontmatter(BaseModel):
42
42
 
43
43
  @property
44
44
  def tags(self) -> List[str]:
45
- return self.metadata.get("tags") if self.metadata else [] # pyright: ignore
45
+ return self.metadata.get("tags") if self.metadata else None # pyright: ignore
46
46
 
47
47
  @property
48
48
  def title(self) -> str:
@@ -1,13 +1,14 @@
1
1
  """Utilities for converting between markdown and entity models."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import Optional, Any
4
+ from typing import Any, Optional
5
5
 
6
6
  from frontmatter import Post
7
7
 
8
+ from basic_memory.file_utils import has_frontmatter, remove_frontmatter, parse_frontmatter
8
9
  from basic_memory.markdown import EntityMarkdown
9
- from basic_memory.models import Entity, Observation as ObservationModel
10
- from basic_memory.utils import generate_permalink
10
+ from basic_memory.models import Entity
11
+ from basic_memory.models import Observation as ObservationModel
11
12
 
12
13
 
13
14
  def entity_model_from_markdown(
@@ -31,17 +32,16 @@ def entity_model_from_markdown(
31
32
  if not markdown.created or not markdown.modified: # pragma: no cover
32
33
  raise ValueError("Both created and modified dates are required in markdown")
33
34
 
34
- # Generate permalink if not provided
35
- permalink = markdown.frontmatter.permalink or generate_permalink(file_path)
36
-
37
35
  # Create or update entity
38
36
  model = entity or Entity()
39
37
 
40
38
  # Update basic fields
41
39
  model.title = markdown.frontmatter.title
42
40
  model.entity_type = markdown.frontmatter.type
43
- model.permalink = permalink
44
- model.file_path = str(file_path)
41
+ # Only update permalink if it exists in frontmatter, otherwise preserve existing
42
+ if markdown.frontmatter.permalink is not None:
43
+ model.permalink = markdown.frontmatter.permalink
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
@@ -76,18 +76,33 @@ async def schema_to_markdown(schema: Any) -> Post:
76
76
  """
77
77
  # Extract content and metadata
78
78
  content = schema.content or ""
79
- frontmatter_metadata = dict(schema.entity_metadata or {})
79
+ entity_metadata = dict(schema.entity_metadata or {})
80
+
81
+ # if the content contains frontmatter, remove it and merge
82
+ if has_frontmatter(content):
83
+ content_frontmatter = parse_frontmatter(content)
84
+ content = remove_frontmatter(content)
85
+
86
+ # Merge content frontmatter with entity metadata
87
+ # (entity_metadata takes precedence for conflicts)
88
+ content_frontmatter.update(entity_metadata)
89
+ entity_metadata = content_frontmatter
80
90
 
81
91
  # Remove special fields for ordered frontmatter
82
92
  for field in ["type", "title", "permalink"]:
83
- frontmatter_metadata.pop(field, None)
93
+ entity_metadata.pop(field, None)
84
94
 
85
- # Create Post with ordered fields
95
+ # Create Post with fields ordered by insert order
86
96
  post = Post(
87
97
  content,
88
98
  title=schema.title,
89
99
  type=schema.entity_type,
90
- permalink=schema.permalink,
91
- **frontmatter_metadata,
92
100
  )
101
+ # set the permalink if passed in
102
+ if schema.permalink:
103
+ post.metadata["permalink"] = schema.permalink
104
+
105
+ if entity_metadata:
106
+ post.metadata.update(entity_metadata)
107
+
93
108
  return post
@@ -1,8 +1,138 @@
1
- from httpx import ASGITransport, AsyncClient
1
+ from contextlib import asynccontextmanager, AbstractAsyncContextManager
2
+ from typing import AsyncIterator, Callable, Optional
3
+
4
+ from httpx import ASGITransport, AsyncClient, Timeout
5
+ from loguru import logger
2
6
 
3
7
  from basic_memory.api.app import app as fastapi_app
8
+ from basic_memory.config import ConfigManager
9
+
10
+
11
+ # Optional factory override for dependency injection
12
+ _client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None
13
+
14
+
15
+ def set_client_factory(factory: Callable[[], AbstractAsyncContextManager[AsyncClient]]) -> None:
16
+ """Override the default client factory (for cloud app, testing, etc).
17
+
18
+ Args:
19
+ factory: An async context manager that yields an AsyncClient
20
+
21
+ Example:
22
+ @asynccontextmanager
23
+ async def custom_client_factory():
24
+ async with AsyncClient(...) as client:
25
+ yield client
26
+
27
+ set_client_factory(custom_client_factory)
28
+ """
29
+ global _client_factory
30
+ _client_factory = factory
31
+
32
+
33
+ @asynccontextmanager
34
+ async def get_client() -> AsyncIterator[AsyncClient]:
35
+ """Get an AsyncClient as a context manager.
36
+
37
+ This function provides proper resource management for HTTP clients,
38
+ ensuring connections are closed after use. It supports three modes:
39
+
40
+ 1. **Factory injection** (cloud app, tests):
41
+ If a custom factory is set via set_client_factory(), use that.
42
+
43
+ 2. **CLI cloud mode**:
44
+ When cloud_mode_enabled is True, create HTTP client with auth
45
+ token from CLIAuth for requests to cloud proxy endpoint.
46
+
47
+ 3. **Local mode** (default):
48
+ Use ASGI transport for in-process requests to local FastAPI app.
49
+
50
+ Usage:
51
+ async with get_client() as client:
52
+ response = await client.get("/path")
53
+
54
+ Yields:
55
+ AsyncClient: Configured HTTP client for the current mode
56
+
57
+ Raises:
58
+ RuntimeError: If cloud mode is enabled but user is not authenticated
59
+ """
60
+ if _client_factory:
61
+ # Use injected factory (cloud app, tests)
62
+ async with _client_factory() as client:
63
+ yield client
64
+ else:
65
+ # Default: create based on config
66
+ config = ConfigManager().config
67
+ timeout = Timeout(
68
+ connect=10.0, # 10 seconds for connection
69
+ read=30.0, # 30 seconds for reading response
70
+ write=30.0, # 30 seconds for writing request
71
+ pool=30.0, # 30 seconds for connection pool
72
+ )
73
+
74
+ if config.cloud_mode_enabled:
75
+ # CLI cloud mode: inject auth when creating client
76
+ from basic_memory.cli.auth import CLIAuth
77
+
78
+ auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
79
+ token = await auth.get_valid_token()
80
+
81
+ if not token:
82
+ raise RuntimeError(
83
+ "Cloud mode enabled but not authenticated. "
84
+ "Run 'basic-memory cloud login' first."
85
+ )
86
+
87
+ # Auth header set ONCE at client creation
88
+ proxy_base_url = f"{config.cloud_host}/proxy"
89
+ logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}")
90
+ async with AsyncClient(
91
+ base_url=proxy_base_url,
92
+ headers={"Authorization": f"Bearer {token}"},
93
+ timeout=timeout,
94
+ ) as client:
95
+ yield client
96
+ else:
97
+ # Local mode: ASGI transport for in-process calls
98
+ logger.info("Creating ASGI client for local Basic Memory API")
99
+ async with AsyncClient(
100
+ transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
101
+ ) as client:
102
+ yield client
103
+
104
+
105
+ def create_client() -> AsyncClient:
106
+ """Create an HTTP client based on configuration.
107
+
108
+ DEPRECATED: Use get_client() context manager instead for proper resource management.
109
+
110
+ This function is kept for backward compatibility but will be removed in a future version.
111
+ The returned client should be closed manually by calling await client.aclose().
112
+
113
+ Returns:
114
+ AsyncClient configured for either local ASGI or remote proxy
115
+ """
116
+ config_manager = ConfigManager()
117
+ config = config_manager.config
4
118
 
5
- BASE_URL = "memory://"
119
+ # Configure timeout for longer operations like write_note
120
+ # Default httpx timeout is 5 seconds which is too short for file operations
121
+ timeout = Timeout(
122
+ connect=10.0, # 10 seconds for connection
123
+ read=30.0, # 30 seconds for reading response
124
+ write=30.0, # 30 seconds for writing request
125
+ pool=30.0, # 30 seconds for connection pool
126
+ )
6
127
 
7
- # Create shared async client
8
- client = AsyncClient(transport=ASGITransport(app=fastapi_app), base_url=BASE_URL)
128
+ if config.cloud_mode_enabled:
129
+ # Use HTTP transport to proxy endpoint
130
+ proxy_base_url = f"{config.cloud_host}/proxy"
131
+ logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}")
132
+ return AsyncClient(base_url=proxy_base_url, timeout=timeout)
133
+ else:
134
+ # Default: use ASGI transport for local API (development mode)
135
+ logger.info("Creating ASGI client for local Basic Memory API")
136
+ return AsyncClient(
137
+ transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
138
+ )