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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/env.py +112 -26
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +45 -24
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/knowledge_router.py +17 -5
- basic_memory/api/routers/project_router.py +68 -14
- basic_memory/api/routers/resource_router.py +37 -27
- basic_memory/api/routers/utils.py +53 -14
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +43 -7
- basic_memory/cli/auth.py +27 -4
- basic_memory/cli/commands/__init__.py +3 -1
- basic_memory/cli/commands/cloud/api_client.py +20 -5
- basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
- basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
- basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
- basic_memory/cli/commands/cloud/upload.py +10 -3
- basic_memory/cli/commands/command_utils.py +52 -4
- basic_memory/cli/commands/db.py +78 -19
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +12 -8
- basic_memory/cli/commands/import_claude_conversations.py +12 -8
- basic_memory/cli/commands/import_claude_projects.py +12 -8
- basic_memory/cli/commands/import_memory_json.py +12 -8
- basic_memory/cli/commands/mcp.py +8 -26
- basic_memory/cli/commands/project.py +22 -9
- basic_memory/cli/commands/status.py +3 -2
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +7 -0
- basic_memory/config.py +177 -77
- basic_memory/db.py +183 -77
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +14 -409
- basic_memory/file_utils.py +212 -3
- basic_memory/ignore_utils.py +5 -5
- basic_memory/importers/base.py +40 -19
- basic_memory/importers/chatgpt_importer.py +17 -4
- basic_memory/importers/claude_conversations_importer.py +27 -12
- basic_memory/importers/claude_projects_importer.py +50 -14
- basic_memory/importers/memory_json_importer.py +36 -16
- basic_memory/importers/utils.py +5 -2
- basic_memory/markdown/entity_parser.py +62 -23
- basic_memory/markdown/markdown_processor.py +67 -4
- basic_memory/markdown/plugins.py +4 -2
- basic_memory/markdown/utils.py +10 -1
- basic_memory/mcp/async_client.py +1 -0
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +47 -33
- basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
- basic_memory/mcp/prompts/recent_activity.py +2 -2
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/server.py +58 -0
- basic_memory/mcp/tools/build_context.py +14 -14
- basic_memory/mcp/tools/canvas.py +34 -12
- basic_memory/mcp/tools/chatgpt_tools.py +4 -1
- basic_memory/mcp/tools/delete_note.py +31 -7
- basic_memory/mcp/tools/edit_note.py +14 -9
- basic_memory/mcp/tools/list_directory.py +7 -17
- basic_memory/mcp/tools/move_note.py +35 -31
- basic_memory/mcp/tools/project_management.py +29 -25
- basic_memory/mcp/tools/read_content.py +13 -3
- basic_memory/mcp/tools/read_note.py +24 -14
- basic_memory/mcp/tools/recent_activity.py +32 -38
- basic_memory/mcp/tools/search.py +17 -10
- basic_memory/mcp/tools/utils.py +28 -0
- basic_memory/mcp/tools/view_note.py +2 -1
- basic_memory/mcp/tools/write_note.py +37 -14
- basic_memory/models/knowledge.py +15 -2
- basic_memory/models/project.py +7 -1
- basic_memory/models/search.py +58 -2
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/entity_repository.py +210 -3
- basic_memory/repository/observation_repository.py +1 -0
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_repository.py +38 -1
- basic_memory/repository/relation_repository.py +58 -2
- basic_memory/repository/repository.py +1 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +77 -615
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/base.py +36 -6
- basic_memory/schemas/directory.py +2 -1
- basic_memory/schemas/memory.py +9 -2
- basic_memory/schemas/project_info.py +2 -0
- basic_memory/schemas/response.py +84 -27
- basic_memory/schemas/search.py +5 -0
- basic_memory/schemas/sync_report.py +1 -1
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/context_service.py +219 -43
- basic_memory/services/directory_service.py +26 -11
- basic_memory/services/entity_service.py +68 -33
- basic_memory/services/file_service.py +131 -16
- basic_memory/services/initialization.py +51 -26
- basic_memory/services/link_resolver.py +1 -0
- basic_memory/services/project_service.py +68 -43
- basic_memory/services/search_service.py +75 -16
- basic_memory/sync/__init__.py +2 -1
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +135 -115
- basic_memory/sync/watch_service.py +32 -12
- basic_memory/telemetry.py +249 -0
- basic_memory/utils.py +96 -75
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- basic_memory-0.16.1.dist-info/RECORD +0 -148
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
#
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
#
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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=
|
|
244
|
+
enqueue=False,
|
|
222
245
|
colorize=False,
|
|
223
246
|
)
|
|
224
247
|
|
|
225
|
-
# Add
|
|
226
|
-
if
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
-
|
|
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
|
-
#
|
|
336
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
#
|
|
444
|
-
|
|
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
|