basic-memory 0.16.1__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 (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,249 @@
1
+ """Anonymous telemetry for Basic Memory (Homebrew-style opt-out).
2
+
3
+ This module implements privacy-respecting usage analytics following the Homebrew model:
4
+ - Telemetry is ON by default
5
+ - Users can easily opt out: `bm telemetry disable`
6
+ - First run shows a one-time notice (not a prompt)
7
+ - Only anonymous data is collected (random UUID, no personal info)
8
+
9
+ What we collect:
10
+ - App version, Python version, OS, architecture
11
+ - Feature usage (which MCP tools and CLI commands are used)
12
+ - Error types (sanitized, no file paths or personal data)
13
+
14
+ What we NEVER collect:
15
+ - Note content, file names, or paths
16
+ - Personal information
17
+ - IP addresses (OpenPanel doesn't store these)
18
+
19
+ Documentation: https://basicmemory.com/telemetry
20
+ """
21
+
22
+ import platform
23
+ import re
24
+ import uuid
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from loguru import logger
29
+ from openpanel import OpenPanel
30
+
31
+ from basic_memory import __version__
32
+
33
+ # --- Configuration ---
34
+
35
+ # OpenPanel credentials (write-only, safe to embed in client code)
36
+ # These can only send events to our dashboard, not read any data
37
+ OPENPANEL_CLIENT_ID = "2e7b036d-c6e5-40aa-91eb-5c70a8ef21a3"
38
+ OPENPANEL_CLIENT_SECRET = "sec_92f7f8328bd0368ff4c2"
39
+
40
+ TELEMETRY_DOCS_URL = "https://basicmemory.com/telemetry"
41
+
42
+ TELEMETRY_NOTICE = f"""
43
+ Basic Memory collects anonymous usage statistics to help improve the software.
44
+ This includes: version, OS, feature usage, and errors. No personal data or note content.
45
+
46
+ To opt out: bm telemetry disable
47
+ Details: {TELEMETRY_DOCS_URL}
48
+ """
49
+
50
+ # --- Module State ---
51
+
52
+ _client: OpenPanel | None = None
53
+ _initialized: bool = False
54
+
55
+
56
+ # --- Installation ID ---
57
+
58
+
59
+ def get_install_id() -> str:
60
+ """Get or create anonymous installation ID.
61
+
62
+ Creates a random UUID on first run and stores it locally.
63
+ User can delete ~/.basic-memory/.install_id to reset.
64
+ """
65
+ id_file = Path.home() / ".basic-memory" / ".install_id"
66
+
67
+ if id_file.exists():
68
+ return id_file.read_text().strip()
69
+
70
+ install_id = str(uuid.uuid4())
71
+ id_file.parent.mkdir(parents=True, exist_ok=True)
72
+ id_file.write_text(install_id)
73
+ return install_id
74
+
75
+
76
+ # --- Client Management ---
77
+
78
+
79
+ def _get_client() -> OpenPanel:
80
+ """Get or create the OpenPanel client (singleton).
81
+
82
+ Lazily initializes the client with global properties.
83
+ """
84
+ global _client, _initialized
85
+
86
+ if _client is None:
87
+ from basic_memory.config import ConfigManager
88
+
89
+ config = ConfigManager().config
90
+
91
+ # Trigger: first call to track an event
92
+ # Why: lazy init avoids work if telemetry never used; disabled flag
93
+ # tells OpenPanel to skip network calls when user opts out or during tests
94
+ # Outcome: client ready to queue events (or silently discard if disabled)
95
+ is_disabled = not config.telemetry_enabled or config.is_test_env
96
+ _client = OpenPanel(
97
+ client_id=OPENPANEL_CLIENT_ID,
98
+ client_secret=OPENPANEL_CLIENT_SECRET,
99
+ disabled=is_disabled,
100
+ )
101
+
102
+ if config.telemetry_enabled and not config.is_test_env and not _initialized:
103
+ # Set global properties that go with every event
104
+ _client.set_global_properties(
105
+ {
106
+ "app_version": __version__,
107
+ "python_version": platform.python_version(),
108
+ "os": platform.system().lower(),
109
+ "arch": platform.machine(),
110
+ "install_id": get_install_id(),
111
+ "source": "foss",
112
+ }
113
+ )
114
+ _initialized = True
115
+
116
+ return _client
117
+
118
+
119
+ def reset_client() -> None:
120
+ """Reset the telemetry client (for testing or after config changes)."""
121
+ global _client, _initialized
122
+ _client = None
123
+ _initialized = False
124
+
125
+
126
+ # --- Event Tracking ---
127
+
128
+
129
+ def track(event: str, properties: dict[str, Any] | None = None) -> None:
130
+ """Track an event. Fire-and-forget, never raises.
131
+
132
+ Args:
133
+ event: Event name (e.g., "app_started", "mcp_tool_called")
134
+ properties: Optional event properties
135
+ """
136
+ # Constraint: telemetry must never break the application
137
+ # Even if OpenPanel API is down or config is corrupt, user's command must succeed
138
+ try:
139
+ _get_client().track(event, properties or {})
140
+ except Exception as e:
141
+ logger.opt(exception=False).debug(f"Telemetry failed: {e}")
142
+
143
+
144
+ # --- First-Run Notice ---
145
+
146
+
147
+ def show_notice_if_needed() -> None:
148
+ """Show one-time telemetry notice (Homebrew style).
149
+
150
+ Only shows if:
151
+ - Telemetry is enabled
152
+ - Notice hasn't been shown before
153
+
154
+ After showing, marks the notice as shown in config.
155
+ """
156
+ from basic_memory.config import ConfigManager
157
+
158
+ config_manager = ConfigManager()
159
+ config = config_manager.config
160
+
161
+ if config.telemetry_enabled and not config.telemetry_notice_shown:
162
+ from rich.console import Console
163
+ from rich.panel import Panel
164
+
165
+ # Print to stderr so it doesn't interfere with command output
166
+ console = Console(stderr=True)
167
+ console.print(
168
+ Panel(
169
+ TELEMETRY_NOTICE.strip(),
170
+ title="[dim]Telemetry Notice[/dim]",
171
+ border_style="dim",
172
+ expand=False,
173
+ )
174
+ )
175
+
176
+ # Mark as shown so we don't show again
177
+ config.telemetry_notice_shown = True
178
+ config_manager.save_config(config)
179
+
180
+
181
+ # --- Convenience Functions ---
182
+
183
+
184
+ def track_app_started(mode: str) -> None:
185
+ """Track app startup.
186
+
187
+ Args:
188
+ mode: "cli" or "mcp"
189
+ """
190
+ track("app_started", {"mode": mode})
191
+
192
+
193
+ def track_mcp_tool(tool_name: str) -> None:
194
+ """Track MCP tool usage.
195
+
196
+ Args:
197
+ tool_name: Name of the tool (e.g., "write_note", "search_notes")
198
+ """
199
+ track("mcp_tool_called", {"tool": tool_name})
200
+
201
+
202
+ def track_cli_command(command: str) -> None:
203
+ """Track CLI command usage.
204
+
205
+ Args:
206
+ command: Command name (e.g., "sync", "import claude")
207
+ """
208
+ track("cli_command", {"command": command})
209
+
210
+
211
+ def track_sync_completed(entity_count: int, duration_ms: int) -> None:
212
+ """Track sync completion.
213
+
214
+ Args:
215
+ entity_count: Number of entities synced
216
+ duration_ms: Duration in milliseconds
217
+ """
218
+ track("sync_completed", {"entity_count": entity_count, "duration_ms": duration_ms})
219
+
220
+
221
+ def track_import_completed(source: str, count: int) -> None:
222
+ """Track import completion.
223
+
224
+ Args:
225
+ source: Import source (e.g., "claude", "chatgpt")
226
+ count: Number of items imported
227
+ """
228
+ track("import_completed", {"source": source, "count": count})
229
+
230
+
231
+ def track_error(error_type: str, message: str) -> None:
232
+ """Track an error (sanitized).
233
+
234
+ Args:
235
+ error_type: Exception class name
236
+ message: Error message (will be sanitized to remove file paths)
237
+ """
238
+ if not message:
239
+ track("error", {"type": error_type, "message": ""})
240
+ return
241
+
242
+ # Sanitize file paths to prevent leaking user directory structure
243
+ # Unix paths: /Users/name/file.py, /home/user/notes/doc.md
244
+ sanitized = re.sub(r"/[\w/.+-]+\.\w+", "[FILE]", message)
245
+ # Windows paths: C:\Users\name\file.py, D:\projects\doc.md
246
+ sanitized = re.sub(r"[A-Z]:\\[\w\\.+-]+\.\w+", "[FILE]", sanitized, flags=re.IGNORECASE)
247
+
248
+ # Truncate to avoid sending too much data
249
+ track("error", {"type": error_type, "message": sanitized[:200]})
basic_memory/utils.py CHANGED
@@ -5,9 +5,9 @@ import os
5
5
  import logging
6
6
  import re
7
7
  import sys
8
- from datetime import datetime
8
+ from datetime import datetime, timezone
9
9
  from pathlib import Path
10
- from typing import Optional, Protocol, Union, runtime_checkable, List
10
+ from typing import Protocol, Union, runtime_checkable, List
11
11
 
12
12
  from loguru import logger
13
13
  from unidecode import unidecode
@@ -42,7 +42,7 @@ def normalize_project_path(path: str) -> str:
42
42
  # Windows paths have a drive letter followed by a colon
43
43
  if len(path) >= 2 and path[1] == ":":
44
44
  # Windows absolute path - return unchanged
45
- return path
45
+ return path # pragma: no cover
46
46
 
47
47
  # Handle both absolute and relative Unix paths
48
48
  normalized = path.lstrip("/")
@@ -67,19 +67,20 @@ class PathLike(Protocol):
67
67
  # This preserves compatibility with existing code while we migrate
68
68
  FilePath = Union[Path, str]
69
69
 
70
- # Disable the "Queue is full" warning
71
- logging.getLogger("opentelemetry.sdk.metrics._internal.instrument").setLevel(logging.ERROR)
72
-
73
70
 
74
71
  def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: bool = True) -> str:
75
72
  """Generate a stable permalink from a file path.
76
73
 
77
74
  Args:
78
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.
79
79
 
80
80
  Returns:
81
81
  Normalized permalink that matches validation rules. Converts spaces and underscores
82
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.
83
84
 
84
85
  Examples:
85
86
  >>> generate_permalink("docs/My Feature.md")
@@ -90,12 +91,26 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
90
91
  'design/unified-model-refactor'
91
92
  >>> generate_permalink("中文/测试文档.md")
92
93
  '中文/测试文档'
94
+ >>> generate_permalink("Version 2.0.0")
95
+ 'version-2.0.0'
93
96
  """
94
97
  # Convert Path to string if needed
95
98
  path_str = Path(str(file_path)).as_posix()
96
99
 
97
- # Remove extension (for now, possibly)
98
- (base, extension) = os.path.splitext(path_str)
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 = ""
99
114
 
100
115
  # Check if we have CJK characters that should be preserved
101
116
  # CJK ranges: \u4e00-\u9fff (CJK Unified Ideographs), \u3000-\u303f (CJK symbols),
@@ -147,9 +162,9 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
147
162
  # Remove apostrophes entirely (don't replace with hyphens)
148
163
  text_no_apostrophes = text_with_hyphens.replace("'", "")
149
164
 
150
- # Replace unsafe chars with hyphens, but preserve CJK characters
165
+ # Replace unsafe chars with hyphens, but preserve CJK characters and periods
151
166
  clean_text = re.sub(
152
- r"[^a-z0-9\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf/\-]", "-", text_no_apostrophes
167
+ r"[^a-z0-9\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf/\-\.]", "-", text_no_apostrophes
153
168
  )
154
169
  else:
155
170
  # Original ASCII-only processing for backward compatibility
@@ -168,8 +183,8 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
168
183
  # Remove apostrophes entirely (don't replace with hyphens)
169
184
  text_no_apostrophes = text_with_hyphens.replace("'", "")
170
185
 
171
- # Replace remaining invalid chars with hyphens
172
- clean_text = re.sub(r"[^a-z0-9/\-]", "-", text_no_apostrophes)
186
+ # Replace remaining invalid chars with hyphens, preserving periods
187
+ clean_text = re.sub(r"[^a-z0-9/\-\.]", "-", text_no_apostrophes)
173
188
 
174
189
  # Collapse multiple hyphens
175
190
  clean_text = re.sub(r"-+", "-", clean_text)
@@ -181,36 +196,44 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
181
196
  return_val = "/".join(clean_segments)
182
197
 
183
198
  # Append file extension back, if necessary
184
- if not split_extension and extension:
185
- return_val += extension
199
+ if not split_extension and extension: # pragma: no cover
200
+ return_val += extension # pragma: no cover
186
201
 
187
202
  return return_val
188
203
 
189
204
 
190
205
  def setup_logging(
191
- env: str,
192
- home_dir: Path,
193
- log_file: Optional[str] = None,
194
206
  log_level: str = "INFO",
195
- console: bool = True,
207
+ log_to_file: bool = False,
208
+ log_to_stdout: bool = False,
209
+ structured_context: bool = False,
196
210
  ) -> None: # pragma: no cover
197
- """
198
- Configure logging for the application.
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.
199
215
 
200
216
  Args:
201
- env: The environment name (dev, test, prod)
202
- home_dir: The root directory for the application
203
- log_file: The name of the log file to write to
204
- log_level: The logging level to use
205
- console: Whether to log to the console
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
206
221
  """
207
222
  # Remove default handler and any existing handlers
208
223
  logger.remove()
209
224
 
210
- # Add file handler if we are not running tests and a log file is specified
211
- if log_file and env != "test":
212
- # Setup file logger
213
- log_path = home_dir / log_file
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
230
+
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.
214
237
  logger.add(
215
238
  str(log_path),
216
239
  level=log_level,
@@ -218,42 +241,28 @@ def setup_logging(
218
241
  retention="10 days",
219
242
  backtrace=True,
220
243
  diagnose=True,
221
- enqueue=True,
244
+ enqueue=False,
222
245
  colorize=False,
223
246
  )
224
247
 
225
- # Add console logger if requested or in test mode
226
- if env == "test" or console:
248
+ # Add stdout handler (for Docker/cloud)
249
+ if log_to_stdout:
227
250
  logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True)
228
251
 
229
- logger.info(f"ENV: '{env}' Log level: '{log_level}' Logging to {log_file}")
230
-
231
- # Bind environment context for structured logging (works in both local and cloud)
232
- tenant_id = os.getenv("BASIC_MEMORY_TENANT_ID", "local")
233
- fly_app_name = os.getenv("FLY_APP_NAME", "local")
234
- fly_machine_id = os.getenv("FLY_MACHINE_ID", "local")
235
- fly_region = os.getenv("FLY_REGION", "local")
236
-
237
- logger.configure(
238
- extra={
239
- "tenant_id": tenant_id,
240
- "fly_app_name": fly_app_name,
241
- "fly_machine_id": fly_machine_id,
242
- "fly_region": fly_region,
243
- }
244
- )
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
+ )
245
262
 
246
263
  # Reduce noise from third-party libraries
247
- noisy_loggers = {
248
- # HTTP client logs
249
- "httpx": logging.WARNING,
250
- # File watching logs
251
- "watchfiles.main": logging.WARNING,
252
- }
253
-
254
- # Set log levels for noisy loggers
255
- for logger_name, level in noisy_loggers.items():
256
- logging.getLogger(logger_name).setLevel(level)
264
+ logging.getLogger("httpx").setLevel(logging.WARNING)
265
+ logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
257
266
 
258
267
 
259
268
  def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
@@ -322,7 +331,7 @@ def normalize_file_path_for_comparison(file_path: str) -> str:
322
331
  This function normalizes file paths to help detect potential conflicts:
323
332
  - Converts to lowercase for case-insensitive comparison
324
333
  - Normalizes Unicode characters
325
- - Handles path separators consistently
334
+ - Converts backslashes to forward slashes for cross-platform consistency
326
335
 
327
336
  Args:
328
337
  file_path: The file path to normalize
@@ -331,19 +340,15 @@ def normalize_file_path_for_comparison(file_path: str) -> str:
331
340
  Normalized file path for comparison purposes
332
341
  """
333
342
  import unicodedata
343
+ from pathlib import PureWindowsPath
334
344
 
335
- # Convert to lowercase for case-insensitive comparison
336
- normalized = file_path.lower()
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()
337
348
 
338
349
  # Normalize Unicode characters (NFD normalization)
339
350
  normalized = unicodedata.normalize("NFD", normalized)
340
351
 
341
- # Replace path separators with forward slashes
342
- normalized = normalized.replace("\\", "/")
343
-
344
- # Remove multiple slashes
345
- normalized = re.sub(r"/+", "/", normalized)
346
-
347
352
  return normalized
348
353
 
349
354
 
@@ -423,25 +428,41 @@ def validate_project_path(path: str, project_path: Path) -> bool:
423
428
  try:
424
429
  resolved = (project_path / path).resolve()
425
430
  return resolved.is_relative_to(project_path.resolve())
426
- except (ValueError, OSError):
427
- return False
431
+ except (ValueError, OSError): # pragma: no cover
432
+ return False # pragma: no cover
433
+
428
434
 
435
+ def ensure_timezone_aware(dt: datetime, cloud_mode: bool | None = None) -> datetime:
436
+ """Ensure a datetime is timezone-aware.
429
437
 
430
- def ensure_timezone_aware(dt: datetime) -> datetime:
431
- """Ensure a datetime is timezone-aware using system timezone.
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
432
442
 
433
- If the datetime is naive, convert it to timezone-aware using the system's local timezone.
434
- If it's already timezone-aware, return it unchanged.
443
+ asyncpg uses binary protocol which returns timestamps in UTC but as naive
444
+ datetimes. In cloud deployments, cloud_mode=True handles this correctly.
435
445
 
436
446
  Args:
437
447
  dt: The datetime to ensure is timezone-aware
448
+ cloud_mode: Optional explicit cloud_mode setting. If None, loads from config.
438
449
 
439
450
  Returns:
440
451
  A timezone-aware datetime
441
452
  """
442
453
  if dt.tzinfo is None:
443
- # Naive datetime - assume it's in local time and add timezone
444
- return dt.astimezone()
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
459
+
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()
445
466
  else:
446
467
  # Already timezone-aware
447
468
  return dt