basic-memory 0.7.0__py3-none-any.whl → 0.17.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 (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/utils.py CHANGED
@@ -1,29 +1,86 @@
1
1
  """Utility functions for basic-memory."""
2
2
 
3
3
  import os
4
+
5
+ import logging
4
6
  import re
5
7
  import sys
8
+ from datetime import datetime, timezone
6
9
  from pathlib import Path
7
- from typing import Optional, Union
10
+ from typing import Protocol, Union, runtime_checkable, List
8
11
 
9
12
  from loguru import logger
10
13
  from unidecode import unidecode
11
14
 
12
- import basic_memory
13
- from basic_memory.config import config
14
15
 
15
- import logfire
16
+ def normalize_project_path(path: str) -> str:
17
+ """Normalize project path by stripping mount point prefix.
18
+
19
+ In cloud deployments, the S3 bucket is mounted at /app/data. We strip this
20
+ prefix from project paths to avoid leaking implementation details and to
21
+ ensure paths match the actual S3 bucket structure.
22
+
23
+ For local paths (including Windows paths), returns the path unchanged.
24
+
25
+ Args:
26
+ path: Project path (e.g., "/app/data/basic-memory-llc" or "C:\\Users\\...")
27
+
28
+ Returns:
29
+ Normalized path (e.g., "/basic-memory-llc" or "C:\\Users\\...")
30
+
31
+ Examples:
32
+ >>> normalize_project_path("/app/data/my-project")
33
+ '/my-project'
34
+ >>> normalize_project_path("/my-project")
35
+ '/my-project'
36
+ >>> normalize_project_path("app/data/my-project")
37
+ '/my-project'
38
+ >>> normalize_project_path("C:\\\\Users\\\\project")
39
+ 'C:\\\\Users\\\\project'
40
+ """
41
+ # Check if this is a Windows absolute path (e.g., C:\Users\...)
42
+ # Windows paths have a drive letter followed by a colon
43
+ if len(path) >= 2 and path[1] == ":":
44
+ # Windows absolute path - return unchanged
45
+ return path # pragma: no cover
46
+
47
+ # Handle both absolute and relative Unix paths
48
+ normalized = path.lstrip("/")
49
+ if normalized.startswith("app/data/"):
50
+ normalized = normalized.removeprefix("app/data/")
51
+
52
+ # Ensure leading slash for Unix absolute paths
53
+ if not normalized.startswith("/"):
54
+ normalized = "/" + normalized
55
+
56
+ return normalized
57
+
16
58
 
59
+ @runtime_checkable
60
+ class PathLike(Protocol):
61
+ """Protocol for objects that can be used as paths."""
17
62
 
18
- def generate_permalink(file_path: Union[Path, str]) -> str:
63
+ def __str__(self) -> str: ...
64
+
65
+
66
+ # In type annotations, use Union[Path, str] instead of FilePath for now
67
+ # This preserves compatibility with existing code while we migrate
68
+ FilePath = Union[Path, str]
69
+
70
+
71
+ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: bool = True) -> str:
19
72
  """Generate a stable permalink from a file path.
20
73
 
21
74
  Args:
22
- file_path: Original file path
75
+ file_path: Original file path (str, Path, or PathLike)
76
+ split_extension: Whether to split off and discard file extensions.
77
+ When True, uses mimetypes to detect real extensions.
78
+ When False, preserves all content including periods.
23
79
 
24
80
  Returns:
25
81
  Normalized permalink that matches validation rules. Converts spaces and underscores
26
- to hyphens for consistency.
82
+ to hyphens for consistency. Preserves non-ASCII characters like Chinese.
83
+ Preserves periods in version numbers (e.g., "2.0.0") when they're not real file extensions.
27
84
 
28
85
  Examples:
29
86
  >>> generate_permalink("docs/My Feature.md")
@@ -32,27 +89,102 @@ def generate_permalink(file_path: Union[Path, str]) -> str:
32
89
  'specs/api-v2'
33
90
  >>> generate_permalink("design/unified_model_refactor.md")
34
91
  'design/unified-model-refactor'
92
+ >>> generate_permalink("中文/测试文档.md")
93
+ '中文/测试文档'
94
+ >>> generate_permalink("Version 2.0.0")
95
+ 'version-2.0.0'
35
96
  """
36
97
  # Convert Path to string if needed
37
- path_str = str(file_path)
98
+ path_str = Path(str(file_path)).as_posix()
99
+
100
+ # Only split extension if there's a real file extension
101
+ # Use mimetypes to detect real extensions, avoiding misinterpreting periods in version numbers
102
+ import mimetypes
103
+
104
+ mime_type, _ = mimetypes.guess_type(path_str)
105
+ has_real_extension = mime_type is not None
106
+
107
+ if has_real_extension and split_extension:
108
+ # Real file extension detected - split it off
109
+ (base, extension) = os.path.splitext(path_str)
110
+ else:
111
+ # No real extension or split_extension=False - process the whole string
112
+ base = path_str
113
+ extension = ""
114
+
115
+ # Check if we have CJK characters that should be preserved
116
+ # CJK ranges: \u4e00-\u9fff (CJK Unified Ideographs), \u3000-\u303f (CJK symbols),
117
+ # \u3400-\u4dbf (CJK Extension A), \uff00-\uffef (Fullwidth forms)
118
+ has_cjk_chars = any(
119
+ "\u4e00" <= char <= "\u9fff"
120
+ or "\u3000" <= char <= "\u303f"
121
+ or "\u3400" <= char <= "\u4dbf"
122
+ or "\uff00" <= char <= "\uffef"
123
+ for char in base
124
+ )
125
+
126
+ if has_cjk_chars:
127
+ # For text with CJK characters, selectively transliterate only Latin accented chars
128
+ result = ""
129
+ for char in base:
130
+ if (
131
+ "\u4e00" <= char <= "\u9fff"
132
+ or "\u3000" <= char <= "\u303f"
133
+ or "\u3400" <= char <= "\u4dbf"
134
+ ):
135
+ # Preserve CJK ideographs and symbols
136
+ result += char
137
+ elif "\uff00" <= char <= "\uffef":
138
+ # Remove Chinese fullwidth punctuation entirely (like ,!?)
139
+ continue
140
+ else:
141
+ # Transliterate Latin accented characters to ASCII
142
+ result += unidecode(char)
143
+
144
+ # Insert hyphens between CJK and Latin character transitions
145
+ # Match: CJK followed by Latin letter/digit, or Latin letter/digit followed by CJK
146
+ result = re.sub(
147
+ r"([\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf])([a-zA-Z0-9])", r"\1-\2", result
148
+ )
149
+ result = re.sub(
150
+ r"([a-zA-Z0-9])([\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf])", r"\1-\2", result
151
+ )
152
+
153
+ # Insert dash between camelCase
154
+ result = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", result)
38
155
 
39
- # Remove extension
40
- base = os.path.splitext(path_str)[0]
156
+ # Convert ASCII letters to lowercase, preserve CJK
157
+ lower_text = "".join(c.lower() if c.isascii() and c.isalpha() else c for c in result)
41
158
 
42
- # Transliterate unicode to ascii
43
- ascii_text = unidecode(base)
159
+ # Replace underscores with hyphens
160
+ text_with_hyphens = lower_text.replace("_", "-")
44
161
 
45
- # Insert dash between camelCase
46
- ascii_text = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", ascii_text)
162
+ # Remove apostrophes entirely (don't replace with hyphens)
163
+ text_no_apostrophes = text_with_hyphens.replace("'", "")
47
164
 
48
- # Convert to lowercase
49
- lower_text = ascii_text.lower()
165
+ # Replace unsafe chars with hyphens, but preserve CJK characters and periods
166
+ clean_text = re.sub(
167
+ r"[^a-z0-9\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf/\-\.]", "-", text_no_apostrophes
168
+ )
169
+ else:
170
+ # Original ASCII-only processing for backward compatibility
171
+ # Transliterate unicode to ascii
172
+ ascii_text = unidecode(base)
173
+
174
+ # Insert dash between camelCase
175
+ ascii_text = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", ascii_text)
176
+
177
+ # Convert to lowercase
178
+ lower_text = ascii_text.lower()
179
+
180
+ # replace underscores with hyphens
181
+ text_with_hyphens = lower_text.replace("_", "-")
50
182
 
51
- # replace underscores with hyphens
52
- text_with_hyphens = lower_text.replace("_", "-")
183
+ # Remove apostrophes entirely (don't replace with hyphens)
184
+ text_no_apostrophes = text_with_hyphens.replace("'", "")
53
185
 
54
- # Replace remaining invalid chars with hyphens
55
- clean_text = re.sub(r"[^a-z0-9/\-]", "-", text_with_hyphens)
186
+ # Replace remaining invalid chars with hyphens, preserving periods
187
+ clean_text = re.sub(r"[^a-z0-9/\-\.]", "-", text_no_apostrophes)
56
188
 
57
189
  # Collapse multiple hyphens
58
190
  clean_text = re.sub(r"-+", "-", clean_text)
@@ -61,55 +193,276 @@ def generate_permalink(file_path: Union[Path, str]) -> str:
61
193
  segments = clean_text.split("/")
62
194
  clean_segments = [s.strip("-") for s in segments]
63
195
 
64
- return "/".join(clean_segments)
196
+ return_val = "/".join(clean_segments)
197
+
198
+ # Append file extension back, if necessary
199
+ if not split_extension and extension: # pragma: no cover
200
+ return_val += extension # pragma: no cover
201
+
202
+ return return_val
65
203
 
66
204
 
67
205
  def setup_logging(
68
- home_dir: Path = config.home, log_file: Optional[str] = None, console: bool = True
206
+ log_level: str = "INFO",
207
+ log_to_file: bool = False,
208
+ log_to_stdout: bool = False,
209
+ structured_context: bool = False,
69
210
  ) -> None: # pragma: no cover
70
- """
71
- Configure logging for the application.
72
- :param home_dir: the root directory for the application
73
- :param log_file: the name of the log file to write to
74
- :param app: the fastapi application instance
75
- :param console: whether to log to the console
76
- """
211
+ """Configure logging with explicit settings.
212
+
213
+ This function provides a simple, explicit interface for configuring logging.
214
+ Each entry point (CLI, MCP, API) should call this with appropriate settings.
77
215
 
216
+ Args:
217
+ log_level: DEBUG, INFO, WARNING, ERROR
218
+ log_to_file: Write to ~/.basic-memory/basic-memory.log with rotation
219
+ log_to_stdout: Write to stderr (for Docker/cloud deployments)
220
+ structured_context: Bind tenant_id, fly_region, etc. for cloud observability
221
+ """
78
222
  # Remove default handler and any existing handlers
79
223
  logger.remove()
80
224
 
81
- # Add file handler if we are not running tests
82
- if log_file and config.env != "test":
83
- # enable pydantic logfire
84
- logfire.configure(
85
- code_source=logfire.CodeSource(
86
- repository="https://github.com/basicmachines-co/basic-memory",
87
- revision=basic_memory.__version__,
88
- root_path="/src/basic_memory",
89
- ),
90
- environment=config.env,
91
- console=False,
92
- )
93
- logger.configure(handlers=[logfire.loguru_handler()])
94
-
95
- # instrument code spans
96
- logfire.instrument_sqlite3()
97
- logfire.instrument_httpx()
225
+ # In test mode, only log to stdout regardless of settings
226
+ env = os.getenv("BASIC_MEMORY_ENV", "dev")
227
+ if env == "test":
228
+ logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True)
229
+ return
98
230
 
99
- # setup logger
100
- log_path = home_dir / log_file
231
+ # Add file handler with rotation
232
+ if log_to_file:
233
+ log_path = Path.home() / ".basic-memory" / "basic-memory.log"
234
+ log_path.parent.mkdir(parents=True, exist_ok=True)
235
+ # Keep logging synchronous (enqueue=False) to avoid background logging threads.
236
+ # Background threads are a common source of "hang on exit" issues in CLI/test runs.
101
237
  logger.add(
102
238
  str(log_path),
103
- level=config.log_level,
104
- rotation="100 MB",
239
+ level=log_level,
240
+ rotation="10 MB",
105
241
  retention="10 days",
106
242
  backtrace=True,
107
243
  diagnose=True,
108
- enqueue=True,
244
+ enqueue=False,
109
245
  colorize=False,
110
246
  )
111
247
 
112
- # Add stderr handler
113
- logger.add(sys.stderr, level=config.log_level, backtrace=True, diagnose=True, colorize=True)
248
+ # Add stdout handler (for Docker/cloud)
249
+ if log_to_stdout:
250
+ logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True)
251
+
252
+ # Bind structured context for cloud observability
253
+ if structured_context:
254
+ logger.configure(
255
+ extra={
256
+ "tenant_id": os.getenv("BASIC_MEMORY_TENANT_ID", "local"),
257
+ "fly_app_name": os.getenv("FLY_APP_NAME", "local"),
258
+ "fly_machine_id": os.getenv("FLY_MACHINE_ID", "local"),
259
+ "fly_region": os.getenv("FLY_REGION", "local"),
260
+ }
261
+ )
262
+
263
+ # Reduce noise from third-party libraries
264
+ logging.getLogger("httpx").setLevel(logging.WARNING)
265
+ logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
266
+
267
+
268
+ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
269
+ """Parse tags from various input formats into a consistent list.
270
+
271
+ Args:
272
+ tags: Can be a list of strings, a comma-separated string, or None
273
+
274
+ Returns:
275
+ A list of tag strings, or an empty list if no tags
276
+
277
+ Note:
278
+ This function strips leading '#' characters from tags to prevent
279
+ their accumulation when tags are processed multiple times.
280
+ """
281
+ if tags is None:
282
+ return []
283
+
284
+ # Process list of tags
285
+ if isinstance(tags, list):
286
+ # First strip whitespace, then strip leading '#' characters to prevent accumulation
287
+ return [tag.strip().lstrip("#") for tag in tags if tag and tag.strip()]
288
+
289
+ # Process string input
290
+ if isinstance(tags, str):
291
+ # Check if it's a JSON array string (common issue from AI assistants)
292
+ import json
293
+
294
+ if tags.strip().startswith("[") and tags.strip().endswith("]"):
295
+ try:
296
+ # Try to parse as JSON array
297
+ parsed_json = json.loads(tags)
298
+ if isinstance(parsed_json, list):
299
+ # Recursively parse the JSON array as a list
300
+ return parse_tags(parsed_json)
301
+ except json.JSONDecodeError:
302
+ # Not valid JSON, fall through to comma-separated parsing
303
+ pass
304
+
305
+ # Split by comma, strip whitespace, then strip leading '#' characters
306
+ return [tag.strip().lstrip("#") for tag in tags.split(",") if tag and tag.strip()]
307
+
308
+ # For any other type, try to convert to string and parse
309
+ try: # pragma: no cover
310
+ return parse_tags(str(tags))
311
+ except (ValueError, TypeError): # pragma: no cover
312
+ logger.warning(f"Couldn't parse tags from input of type {type(tags)}: {tags}")
313
+ return []
314
+
315
+
316
+ def normalize_newlines(multiline: str) -> str:
317
+ """Replace any \r\n, \r, or \n with the native newline.
318
+
319
+ Args:
320
+ multiline: String containing any mixture of newlines.
321
+
322
+ Returns:
323
+ A string with normalized newlines native to the platform.
324
+ """
325
+ return re.sub(r"\r\n?|\n", os.linesep, multiline)
326
+
327
+
328
+ def normalize_file_path_for_comparison(file_path: str) -> str:
329
+ """Normalize a file path for conflict detection.
330
+
331
+ This function normalizes file paths to help detect potential conflicts:
332
+ - Converts to lowercase for case-insensitive comparison
333
+ - Normalizes Unicode characters
334
+ - Converts backslashes to forward slashes for cross-platform consistency
335
+
336
+ Args:
337
+ file_path: The file path to normalize
338
+
339
+ Returns:
340
+ Normalized file path for comparison purposes
341
+ """
342
+ import unicodedata
343
+ from pathlib import PureWindowsPath
344
+
345
+ # Use PureWindowsPath to ensure backslashes are treated as separators
346
+ # regardless of current platform, then convert to POSIX-style
347
+ normalized = PureWindowsPath(file_path).as_posix().lower()
348
+
349
+ # Normalize Unicode characters (NFD normalization)
350
+ normalized = unicodedata.normalize("NFD", normalized)
351
+
352
+ return normalized
353
+
354
+
355
+ def detect_potential_file_conflicts(file_path: str, existing_paths: List[str]) -> List[str]:
356
+ """Detect potential conflicts between a file path and existing paths.
357
+
358
+ This function checks for various types of conflicts:
359
+ - Case sensitivity differences
360
+ - Unicode normalization differences
361
+ - Path separator differences
362
+ - Permalink generation conflicts
363
+
364
+ Args:
365
+ file_path: The file path to check
366
+ existing_paths: List of existing file paths to check against
367
+
368
+ Returns:
369
+ List of existing paths that might conflict with the given file path
370
+ """
371
+ conflicts = []
372
+
373
+ # Normalize the input file path
374
+ normalized_input = normalize_file_path_for_comparison(file_path)
375
+ input_permalink = generate_permalink(file_path)
376
+
377
+ for existing_path in existing_paths:
378
+ # Skip identical paths
379
+ if existing_path == file_path:
380
+ continue
381
+
382
+ # Check for case-insensitive path conflicts
383
+ normalized_existing = normalize_file_path_for_comparison(existing_path)
384
+ if normalized_input == normalized_existing:
385
+ conflicts.append(existing_path)
386
+ continue
387
+
388
+ # Check for permalink conflicts
389
+ existing_permalink = generate_permalink(existing_path)
390
+ if input_permalink == existing_permalink:
391
+ conflicts.append(existing_path)
392
+ continue
393
+
394
+ return conflicts
395
+
396
+
397
+ def valid_project_path_value(path: str):
398
+ """Ensure project path is valid."""
399
+ # Allow empty strings as they resolve to the project root
400
+ if not path:
401
+ return True
402
+
403
+ # Check for obvious path traversal patterns first
404
+ if ".." in path or "~" in path:
405
+ return False
406
+
407
+ # Check for Windows-style path traversal (even on Unix systems)
408
+ if "\\.." in path or path.startswith("\\"):
409
+ return False
410
+
411
+ # Block absolute paths (Unix-style starting with / or Windows-style with drive letters)
412
+ if path.startswith("/") or (len(path) >= 2 and path[1] == ":"):
413
+ return False
414
+
415
+ # Block paths with control characters (but allow whitespace that will be stripped)
416
+ if path.strip() and any(ord(c) < 32 and c not in [" ", "\t"] for c in path):
417
+ return False
418
+
419
+ return True
420
+
421
+
422
+ def validate_project_path(path: str, project_path: Path) -> bool:
423
+ """Ensure path is valid and stays within project boundaries."""
424
+
425
+ if not valid_project_path_value(path):
426
+ return False
427
+
428
+ try:
429
+ resolved = (project_path / path).resolve()
430
+ return resolved.is_relative_to(project_path.resolve())
431
+ except (ValueError, OSError): # pragma: no cover
432
+ return False # pragma: no cover
433
+
434
+
435
+ def ensure_timezone_aware(dt: datetime, cloud_mode: bool | None = None) -> datetime:
436
+ """Ensure a datetime is timezone-aware.
437
+
438
+ If the datetime is naive, convert it to timezone-aware. The interpretation
439
+ depends on cloud_mode:
440
+ - In cloud mode (PostgreSQL/asyncpg): naive datetimes are interpreted as UTC
441
+ - In local mode (SQLite): naive datetimes are interpreted as local time
442
+
443
+ asyncpg uses binary protocol which returns timestamps in UTC but as naive
444
+ datetimes. In cloud deployments, cloud_mode=True handles this correctly.
445
+
446
+ Args:
447
+ dt: The datetime to ensure is timezone-aware
448
+ cloud_mode: Optional explicit cloud_mode setting. If None, loads from config.
449
+
450
+ Returns:
451
+ A timezone-aware datetime
452
+ """
453
+ if dt.tzinfo is None:
454
+ # Determine cloud_mode: use explicit parameter if provided, otherwise load from config
455
+ if cloud_mode is None:
456
+ from basic_memory.config import ConfigManager
457
+
458
+ cloud_mode = ConfigManager().config.cloud_mode_enabled
114
459
 
115
- logger.info(f"ENV: '{config.env}' Log level: '{config.log_level}' Logging to {log_file}")
460
+ if cloud_mode:
461
+ # Cloud/PostgreSQL mode: naive datetimes from asyncpg are already UTC
462
+ return dt.replace(tzinfo=timezone.utc)
463
+ else:
464
+ # Local/SQLite mode: naive datetimes are in local time
465
+ return dt.astimezone()
466
+ else:
467
+ # Already timezone-aware
468
+ return dt