basic-memory 0.14.4__py3-none-any.whl → 0.15.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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
- basic_memory/api/app.py +10 -4
- basic_memory/api/routers/directory_router.py +23 -2
- basic_memory/api/routers/knowledge_router.py +25 -8
- basic_memory/api/routers/project_router.py +100 -4
- basic_memory/cli/app.py +9 -28
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/cloud/__init__.py +5 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
- basic_memory/cli/commands/cloud/core_commands.py +288 -0
- basic_memory/cli/commands/cloud/mount_commands.py +295 -0
- basic_memory/cli/commands/cloud/rclone_config.py +288 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
- basic_memory/cli/commands/command_utils.py +43 -0
- basic_memory/cli/commands/import_memory_json.py +0 -4
- basic_memory/cli/commands/mcp.py +77 -60
- basic_memory/cli/commands/project.py +154 -152
- basic_memory/cli/commands/status.py +25 -22
- basic_memory/cli/commands/sync.py +45 -228
- basic_memory/cli/commands/tool.py +87 -16
- basic_memory/cli/main.py +1 -0
- basic_memory/config.py +131 -21
- basic_memory/db.py +104 -3
- basic_memory/deps.py +27 -8
- basic_memory/file_utils.py +37 -13
- basic_memory/ignore_utils.py +295 -0
- basic_memory/markdown/plugins.py +9 -7
- basic_memory/mcp/async_client.py +124 -14
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
- basic_memory/mcp/prompts/continue_conversation.py +17 -16
- basic_memory/mcp/prompts/recent_activity.py +116 -32
- basic_memory/mcp/prompts/search.py +13 -12
- basic_memory/mcp/prompts/utils.py +11 -4
- basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
- basic_memory/mcp/resources/project_info.py +27 -11
- basic_memory/mcp/server.py +0 -37
- basic_memory/mcp/tools/__init__.py +5 -6
- basic_memory/mcp/tools/build_context.py +67 -56
- basic_memory/mcp/tools/canvas.py +38 -26
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +81 -47
- basic_memory/mcp/tools/edit_note.py +155 -138
- basic_memory/mcp/tools/list_directory.py +112 -99
- basic_memory/mcp/tools/move_note.py +181 -101
- basic_memory/mcp/tools/project_management.py +113 -277
- basic_memory/mcp/tools/read_content.py +91 -74
- basic_memory/mcp/tools/read_note.py +152 -115
- basic_memory/mcp/tools/recent_activity.py +471 -68
- basic_memory/mcp/tools/search.py +105 -92
- basic_memory/mcp/tools/sync_status.py +136 -130
- basic_memory/mcp/tools/utils.py +4 -0
- basic_memory/mcp/tools/view_note.py +44 -33
- basic_memory/mcp/tools/write_note.py +151 -90
- basic_memory/models/knowledge.py +12 -6
- basic_memory/models/project.py +6 -2
- basic_memory/repository/entity_repository.py +89 -82
- basic_memory/repository/relation_repository.py +13 -0
- basic_memory/repository/repository.py +18 -5
- basic_memory/repository/search_repository.py +46 -2
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +39 -11
- basic_memory/schemas/cloud.py +46 -0
- basic_memory/schemas/memory.py +90 -21
- basic_memory/schemas/project_info.py +9 -10
- basic_memory/schemas/sync_report.py +48 -0
- basic_memory/services/context_service.py +25 -11
- basic_memory/services/directory_service.py +124 -3
- basic_memory/services/entity_service.py +100 -48
- basic_memory/services/initialization.py +30 -11
- basic_memory/services/project_service.py +101 -24
- basic_memory/services/search_service.py +16 -8
- basic_memory/sync/sync_service.py +173 -34
- basic_memory/sync/watch_service.py +101 -40
- basic_memory/utils.py +14 -4
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
- basic_memory-0.15.1.dist-info/RECORD +146 -0
- basic_memory/mcp/project_session.py +0 -120
- basic_memory-0.14.4.dist-info/RECORD +0 -133
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Utilities for handling .gitignore patterns and file filtering."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Set
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Common directories and patterns to ignore by default
|
|
9
|
+
# These are used as fallback if .bmignore doesn't exist
|
|
10
|
+
DEFAULT_IGNORE_PATTERNS = {
|
|
11
|
+
# Hidden files (files starting with dot)
|
|
12
|
+
".*",
|
|
13
|
+
# Basic Memory internal files
|
|
14
|
+
"*.db",
|
|
15
|
+
"*.db-shm",
|
|
16
|
+
"*.db-wal",
|
|
17
|
+
"config.json",
|
|
18
|
+
# Version control
|
|
19
|
+
".git",
|
|
20
|
+
".svn",
|
|
21
|
+
# Python
|
|
22
|
+
"__pycache__",
|
|
23
|
+
"*.pyc",
|
|
24
|
+
"*.pyo",
|
|
25
|
+
"*.pyd",
|
|
26
|
+
".pytest_cache",
|
|
27
|
+
".coverage",
|
|
28
|
+
"*.egg-info",
|
|
29
|
+
".tox",
|
|
30
|
+
".mypy_cache",
|
|
31
|
+
".ruff_cache",
|
|
32
|
+
# Virtual environments
|
|
33
|
+
".venv",
|
|
34
|
+
"venv",
|
|
35
|
+
"env",
|
|
36
|
+
".env",
|
|
37
|
+
# Node.js
|
|
38
|
+
"node_modules",
|
|
39
|
+
# Build artifacts
|
|
40
|
+
"build",
|
|
41
|
+
"dist",
|
|
42
|
+
".cache",
|
|
43
|
+
# IDE
|
|
44
|
+
".idea",
|
|
45
|
+
".vscode",
|
|
46
|
+
# OS files
|
|
47
|
+
".DS_Store",
|
|
48
|
+
"Thumbs.db",
|
|
49
|
+
"desktop.ini",
|
|
50
|
+
# Obsidian
|
|
51
|
+
".obsidian",
|
|
52
|
+
# Temporary files
|
|
53
|
+
"*.tmp",
|
|
54
|
+
"*.swp",
|
|
55
|
+
"*.swo",
|
|
56
|
+
"*~",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_bmignore_path() -> Path:
|
|
61
|
+
"""Get path to .bmignore file.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Path to ~/.basic-memory/.bmignore
|
|
65
|
+
"""
|
|
66
|
+
return Path.home() / ".basic-memory" / ".bmignore"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def create_default_bmignore() -> None:
|
|
70
|
+
"""Create default .bmignore file if it doesn't exist.
|
|
71
|
+
|
|
72
|
+
This ensures users have a file they can customize for all Basic Memory operations.
|
|
73
|
+
"""
|
|
74
|
+
bmignore_path = get_bmignore_path()
|
|
75
|
+
|
|
76
|
+
if bmignore_path.exists():
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
bmignore_path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
bmignore_path.write_text("""# Basic Memory Ignore Patterns
|
|
81
|
+
# This file is used by both 'bm cloud upload', 'bm cloud bisync', and file sync
|
|
82
|
+
# Patterns use standard gitignore-style syntax
|
|
83
|
+
|
|
84
|
+
# Hidden files (files starting with dot)
|
|
85
|
+
.*
|
|
86
|
+
|
|
87
|
+
# Basic Memory internal files (includes test databases)
|
|
88
|
+
*.db
|
|
89
|
+
*.db-shm
|
|
90
|
+
*.db-wal
|
|
91
|
+
config.json
|
|
92
|
+
|
|
93
|
+
# Version control
|
|
94
|
+
.git
|
|
95
|
+
.svn
|
|
96
|
+
|
|
97
|
+
# Python
|
|
98
|
+
__pycache__
|
|
99
|
+
*.pyc
|
|
100
|
+
*.pyo
|
|
101
|
+
*.pyd
|
|
102
|
+
.pytest_cache
|
|
103
|
+
.coverage
|
|
104
|
+
*.egg-info
|
|
105
|
+
.tox
|
|
106
|
+
.mypy_cache
|
|
107
|
+
.ruff_cache
|
|
108
|
+
|
|
109
|
+
# Virtual environments
|
|
110
|
+
.venv
|
|
111
|
+
venv
|
|
112
|
+
env
|
|
113
|
+
.env
|
|
114
|
+
|
|
115
|
+
# Node.js
|
|
116
|
+
node_modules
|
|
117
|
+
|
|
118
|
+
# Build artifacts
|
|
119
|
+
build
|
|
120
|
+
dist
|
|
121
|
+
.cache
|
|
122
|
+
|
|
123
|
+
# IDE
|
|
124
|
+
.idea
|
|
125
|
+
.vscode
|
|
126
|
+
|
|
127
|
+
# OS files
|
|
128
|
+
.DS_Store
|
|
129
|
+
Thumbs.db
|
|
130
|
+
desktop.ini
|
|
131
|
+
|
|
132
|
+
# Obsidian
|
|
133
|
+
.obsidian
|
|
134
|
+
|
|
135
|
+
# Temporary files
|
|
136
|
+
*.tmp
|
|
137
|
+
*.swp
|
|
138
|
+
*.swo
|
|
139
|
+
*~
|
|
140
|
+
""")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_bmignore_patterns() -> Set[str]:
|
|
144
|
+
"""Load patterns from .bmignore file.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Set of patterns from .bmignore, or DEFAULT_IGNORE_PATTERNS if file doesn't exist
|
|
148
|
+
"""
|
|
149
|
+
bmignore_path = get_bmignore_path()
|
|
150
|
+
|
|
151
|
+
# Create default file if it doesn't exist
|
|
152
|
+
if not bmignore_path.exists():
|
|
153
|
+
create_default_bmignore()
|
|
154
|
+
|
|
155
|
+
patterns = set()
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
with bmignore_path.open("r", encoding="utf-8") as f:
|
|
159
|
+
for line in f:
|
|
160
|
+
line = line.strip()
|
|
161
|
+
# Skip empty lines and comments
|
|
162
|
+
if line and not line.startswith("#"):
|
|
163
|
+
patterns.add(line)
|
|
164
|
+
except Exception:
|
|
165
|
+
# If we can't read .bmignore, fall back to defaults
|
|
166
|
+
return set(DEFAULT_IGNORE_PATTERNS)
|
|
167
|
+
|
|
168
|
+
# If no patterns were loaded, use defaults
|
|
169
|
+
if not patterns:
|
|
170
|
+
return set(DEFAULT_IGNORE_PATTERNS)
|
|
171
|
+
|
|
172
|
+
return patterns
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load_gitignore_patterns(base_path: Path) -> Set[str]:
|
|
176
|
+
"""Load gitignore patterns from .gitignore file and .bmignore.
|
|
177
|
+
|
|
178
|
+
Combines patterns from:
|
|
179
|
+
1. ~/.basic-memory/.bmignore (user's global ignore patterns)
|
|
180
|
+
2. {base_path}/.gitignore (project-specific patterns)
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
base_path: The base directory to search for .gitignore file
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Set of patterns to ignore
|
|
187
|
+
"""
|
|
188
|
+
# Start with patterns from .bmignore
|
|
189
|
+
patterns = load_bmignore_patterns()
|
|
190
|
+
|
|
191
|
+
gitignore_file = base_path / ".gitignore"
|
|
192
|
+
if gitignore_file.exists():
|
|
193
|
+
try:
|
|
194
|
+
with gitignore_file.open("r", encoding="utf-8") as f:
|
|
195
|
+
for line in f:
|
|
196
|
+
line = line.strip()
|
|
197
|
+
# Skip empty lines and comments
|
|
198
|
+
if line and not line.startswith("#"):
|
|
199
|
+
patterns.add(line)
|
|
200
|
+
except Exception:
|
|
201
|
+
# If we can't read .gitignore, just use default patterns
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
return patterns
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def should_ignore_path(file_path: Path, base_path: Path, ignore_patterns: Set[str]) -> bool:
|
|
208
|
+
"""Check if a file path should be ignored based on gitignore patterns.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
file_path: The file path to check
|
|
212
|
+
base_path: The base directory for relative path calculation
|
|
213
|
+
ignore_patterns: Set of patterns to match against
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
True if the path should be ignored, False otherwise
|
|
217
|
+
"""
|
|
218
|
+
# Get the relative path from base
|
|
219
|
+
try:
|
|
220
|
+
relative_path = file_path.relative_to(base_path)
|
|
221
|
+
relative_str = str(relative_path)
|
|
222
|
+
relative_posix = relative_path.as_posix() # Use forward slashes for matching
|
|
223
|
+
|
|
224
|
+
# Check each pattern
|
|
225
|
+
for pattern in ignore_patterns:
|
|
226
|
+
# Handle patterns starting with / (root relative)
|
|
227
|
+
if pattern.startswith("/"):
|
|
228
|
+
root_pattern = pattern[1:] # Remove leading /
|
|
229
|
+
|
|
230
|
+
# For directory patterns ending with /
|
|
231
|
+
if root_pattern.endswith("/"):
|
|
232
|
+
dir_name = root_pattern[:-1] # Remove trailing /
|
|
233
|
+
# Check if the first part of the path matches the directory name
|
|
234
|
+
if len(relative_path.parts) > 0 and relative_path.parts[0] == dir_name:
|
|
235
|
+
return True
|
|
236
|
+
else:
|
|
237
|
+
# Regular root-relative pattern
|
|
238
|
+
if fnmatch.fnmatch(relative_posix, root_pattern):
|
|
239
|
+
return True
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# Handle directory patterns (ending with /)
|
|
243
|
+
if pattern.endswith("/"):
|
|
244
|
+
dir_name = pattern[:-1] # Remove trailing /
|
|
245
|
+
# Check if any path part matches the directory name
|
|
246
|
+
if dir_name in relative_path.parts:
|
|
247
|
+
return True
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
# Direct name match (e.g., ".git", "node_modules")
|
|
251
|
+
if pattern in relative_path.parts:
|
|
252
|
+
return True
|
|
253
|
+
|
|
254
|
+
# Check if any individual path part matches the glob pattern
|
|
255
|
+
# This handles cases like ".*" matching ".hidden.md" in "concept/.hidden.md"
|
|
256
|
+
for part in relative_path.parts:
|
|
257
|
+
if fnmatch.fnmatch(part, pattern):
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
# Glob pattern match on full path
|
|
261
|
+
if fnmatch.fnmatch(relative_posix, pattern) or fnmatch.fnmatch(relative_str, pattern):
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
return False
|
|
265
|
+
except ValueError:
|
|
266
|
+
# If we can't get relative path, don't ignore
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def filter_files(
|
|
271
|
+
files: list[Path], base_path: Path, ignore_patterns: Set[str] | None = None
|
|
272
|
+
) -> tuple[list[Path], int]:
|
|
273
|
+
"""Filter a list of files based on gitignore patterns.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
files: List of file paths to filter
|
|
277
|
+
base_path: The base directory for relative path calculation
|
|
278
|
+
ignore_patterns: Set of patterns to ignore. If None, loads from .gitignore
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Tuple of (filtered_files, ignored_count)
|
|
282
|
+
"""
|
|
283
|
+
if ignore_patterns is None:
|
|
284
|
+
ignore_patterns = load_gitignore_patterns(base_path)
|
|
285
|
+
|
|
286
|
+
filtered_files = []
|
|
287
|
+
ignored_count = 0
|
|
288
|
+
|
|
289
|
+
for file_path in files:
|
|
290
|
+
if should_ignore_path(file_path, base_path, ignore_patterns):
|
|
291
|
+
ignored_count += 1
|
|
292
|
+
else:
|
|
293
|
+
filtered_files.append(file_path)
|
|
294
|
+
|
|
295
|
+
return filtered_files, ignored_count
|
basic_memory/markdown/plugins.py
CHANGED
|
@@ -9,6 +9,7 @@ from markdown_it.token import Token
|
|
|
9
9
|
def is_observation(token: Token) -> bool:
|
|
10
10
|
"""Check if token looks like our observation format."""
|
|
11
11
|
import re
|
|
12
|
+
|
|
12
13
|
if token.type != "inline": # pragma: no cover
|
|
13
14
|
return False
|
|
14
15
|
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
@@ -18,15 +19,15 @@ def is_observation(token: Token) -> bool:
|
|
|
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
23
|
# Exclude markdown links: [text](url)
|
|
23
24
|
if re.match(r"^\[.*?\]\(.*?\)$", content):
|
|
24
25
|
return False
|
|
25
|
-
|
|
26
|
+
|
|
26
27
|
# Exclude wiki links: [[text]]
|
|
27
28
|
if re.match(r"^\[\[.*?\]\]$", content):
|
|
28
29
|
return False
|
|
29
|
-
|
|
30
|
+
|
|
30
31
|
# Check for proper observation format: [category] content
|
|
31
32
|
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
|
|
32
33
|
has_tags = "#" in content
|
|
@@ -36,9 +37,10 @@ def is_observation(token: Token) -> bool:
|
|
|
36
37
|
def parse_observation(token: Token) -> Dict[str, Any]:
|
|
37
38
|
"""Extract observation parts from token."""
|
|
38
39
|
import re
|
|
40
|
+
|
|
39
41
|
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
40
42
|
content = (token.tag or token.content).strip()
|
|
41
|
-
|
|
43
|
+
|
|
42
44
|
# Parse [category] with regex
|
|
43
45
|
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
|
|
44
46
|
category = None
|
|
@@ -50,7 +52,7 @@ def parse_observation(token: Token) -> Dict[str, Any]:
|
|
|
50
52
|
empty_match = re.match(r"^\[\]\s+(.+)", content)
|
|
51
53
|
if empty_match:
|
|
52
54
|
content = empty_match.group(1).strip()
|
|
53
|
-
|
|
55
|
+
|
|
54
56
|
# Parse (context)
|
|
55
57
|
context = None
|
|
56
58
|
if content.endswith(")"):
|
|
@@ -58,7 +60,7 @@ def parse_observation(token: Token) -> Dict[str, Any]:
|
|
|
58
60
|
if start != -1:
|
|
59
61
|
context = content[start + 1 : -1].strip()
|
|
60
62
|
content = content[:start].strip()
|
|
61
|
-
|
|
63
|
+
|
|
62
64
|
# Extract tags and keep original content
|
|
63
65
|
tags = []
|
|
64
66
|
parts = content.split()
|
|
@@ -69,7 +71,7 @@ def parse_observation(token: Token) -> Dict[str, Any]:
|
|
|
69
71
|
tags.extend(subtags)
|
|
70
72
|
else:
|
|
71
73
|
tags.append(part[1:])
|
|
72
|
-
|
|
74
|
+
|
|
73
75
|
return {
|
|
74
76
|
"category": category,
|
|
75
77
|
"content": content,
|
basic_memory/mcp/async_client.py
CHANGED
|
@@ -1,28 +1,138 @@
|
|
|
1
|
-
from
|
|
1
|
+
from contextlib import asynccontextmanager, AbstractAsyncContextManager
|
|
2
|
+
from typing import AsyncIterator, Callable, Optional
|
|
3
|
+
|
|
4
|
+
from httpx import ASGITransport, AsyncClient, Timeout
|
|
2
5
|
from loguru import logger
|
|
3
6
|
|
|
4
7
|
from basic_memory.api.app import app as fastapi_app
|
|
5
8
|
from basic_memory.config import ConfigManager
|
|
6
9
|
|
|
7
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
|
+
|
|
8
105
|
def create_client() -> AsyncClient:
|
|
9
106
|
"""Create an HTTP client based on configuration.
|
|
10
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
|
+
|
|
11
113
|
Returns:
|
|
12
|
-
AsyncClient configured for either local ASGI or remote
|
|
114
|
+
AsyncClient configured for either local ASGI or remote proxy
|
|
13
115
|
"""
|
|
14
116
|
config_manager = ConfigManager()
|
|
15
|
-
config = config_manager.
|
|
16
|
-
|
|
17
|
-
if config.api_url:
|
|
18
|
-
# Use HTTP transport for remote API
|
|
19
|
-
logger.info(f"Creating HTTP client for remote Basic Memory API: {config.api_url}")
|
|
20
|
-
return AsyncClient(base_url=config.api_url)
|
|
21
|
-
else:
|
|
22
|
-
# Use ASGI transport for local API
|
|
23
|
-
logger.debug("Creating ASGI client for local Basic Memory API")
|
|
24
|
-
return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test")
|
|
117
|
+
config = config_manager.config
|
|
25
118
|
|
|
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
|
+
)
|
|
26
127
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Project context utilities for Basic Memory MCP server.
|
|
2
|
+
|
|
3
|
+
Provides project lookup utilities for MCP tools.
|
|
4
|
+
Handles project validation and context management in one place.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import Optional, List
|
|
9
|
+
from httpx import AsyncClient
|
|
10
|
+
from httpx._types import (
|
|
11
|
+
HeaderTypes,
|
|
12
|
+
)
|
|
13
|
+
from loguru import logger
|
|
14
|
+
from fastmcp import Context
|
|
15
|
+
|
|
16
|
+
from basic_memory.config import ConfigManager
|
|
17
|
+
from basic_memory.mcp.tools.utils import call_get
|
|
18
|
+
from basic_memory.schemas.project_info import ProjectItem, ProjectList
|
|
19
|
+
from basic_memory.utils import generate_permalink
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def resolve_project_parameter(project: Optional[str] = None) -> Optional[str]:
|
|
23
|
+
"""Resolve project parameter using three-tier hierarchy.
|
|
24
|
+
|
|
25
|
+
if config.cloud_mode:
|
|
26
|
+
project is required
|
|
27
|
+
else:
|
|
28
|
+
Resolution order:
|
|
29
|
+
1. Single Project Mode (--project cli arg, or BASIC_MEMORY_MCP_PROJECT env var) - highest priority
|
|
30
|
+
2. Explicit project parameter - medium priority
|
|
31
|
+
3. Default project if default_project_mode=true - lowest priority
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
project: Optional explicit project parameter
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Resolved project name or None if no resolution possible
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
config = ConfigManager().config
|
|
41
|
+
# if cloud_mode, project is required
|
|
42
|
+
if config.cloud_mode:
|
|
43
|
+
if project:
|
|
44
|
+
logger.debug(f"project: {project}, cloud_mode: {config.cloud_mode}")
|
|
45
|
+
return project
|
|
46
|
+
else:
|
|
47
|
+
raise ValueError("No project specified. Project is required for cloud mode.")
|
|
48
|
+
|
|
49
|
+
# Priority 1: CLI constraint overrides everything (--project arg sets env var)
|
|
50
|
+
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
|
51
|
+
if constrained_project:
|
|
52
|
+
logger.debug(f"Using CLI constrained project: {constrained_project}")
|
|
53
|
+
return constrained_project
|
|
54
|
+
|
|
55
|
+
# Priority 2: Explicit project parameter
|
|
56
|
+
if project:
|
|
57
|
+
logger.debug(f"Using explicit project parameter: {project}")
|
|
58
|
+
return project
|
|
59
|
+
|
|
60
|
+
# Priority 3: Default project mode
|
|
61
|
+
if config.default_project_mode:
|
|
62
|
+
logger.debug(f"Using default project from config: {config.default_project}")
|
|
63
|
+
return config.default_project
|
|
64
|
+
|
|
65
|
+
# No resolution possible
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = None) -> List[str]:
|
|
70
|
+
response = await call_get(client, "/projects/projects", headers=headers)
|
|
71
|
+
project_list = ProjectList.model_validate(response.json())
|
|
72
|
+
return [project.name for project in project_list.projects]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def get_active_project(
|
|
76
|
+
client: AsyncClient,
|
|
77
|
+
project: Optional[str] = None,
|
|
78
|
+
context: Optional[Context] = None,
|
|
79
|
+
headers: HeaderTypes | None = None,
|
|
80
|
+
) -> ProjectItem:
|
|
81
|
+
"""Get and validate project, setting it in context if available.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
client: HTTP client for API calls
|
|
85
|
+
project: Optional project name (resolved using hierarchy)
|
|
86
|
+
context: Optional FastMCP context to cache the result
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The validated project item
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: If no project can be resolved
|
|
93
|
+
HTTPError: If project doesn't exist or is inaccessible
|
|
94
|
+
"""
|
|
95
|
+
resolved_project = await resolve_project_parameter(project)
|
|
96
|
+
if not resolved_project:
|
|
97
|
+
project_names = await get_project_names(client, headers)
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"No project specified. "
|
|
100
|
+
"Either set 'default_project_mode=true' in config, or use 'project' argument.\n"
|
|
101
|
+
f"Available projects: {project_names}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
project = resolved_project
|
|
105
|
+
|
|
106
|
+
# Check if already cached in context
|
|
107
|
+
if context:
|
|
108
|
+
cached_project = context.get_state("active_project")
|
|
109
|
+
if cached_project and cached_project.name == project:
|
|
110
|
+
logger.debug(f"Using cached project from context: {project}")
|
|
111
|
+
return cached_project
|
|
112
|
+
|
|
113
|
+
# Validate project exists by calling API
|
|
114
|
+
logger.debug(f"Validating project: {project}")
|
|
115
|
+
permalink = generate_permalink(project)
|
|
116
|
+
response = await call_get(client, f"/{permalink}/project/item", headers=headers)
|
|
117
|
+
active_project = ProjectItem.model_validate(response.json())
|
|
118
|
+
|
|
119
|
+
# Cache in context if available
|
|
120
|
+
if context:
|
|
121
|
+
context.set_state("active_project", active_project)
|
|
122
|
+
logger.debug(f"Cached project in context: {project}")
|
|
123
|
+
|
|
124
|
+
logger.debug(f"Validated project: {active_project.name}")
|
|
125
|
+
return active_project
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def add_project_metadata(result: str, project_name: str) -> str:
|
|
129
|
+
"""Add project context as metadata footer for assistant session tracking.
|
|
130
|
+
|
|
131
|
+
Provides clear project context to help the assistant remember which
|
|
132
|
+
project is being used throughout the conversation session.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
result: The tool result string
|
|
136
|
+
project_name: The project name that was used
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Result with project session tracking metadata
|
|
140
|
+
"""
|
|
141
|
+
return f"{result}\n\n[Session: Using project '{project_name}']"
|