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.

Files changed (84) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/directory_router.py +23 -2
  5. basic_memory/api/routers/knowledge_router.py +25 -8
  6. basic_memory/api/routers/project_router.py +100 -4
  7. basic_memory/cli/app.py +9 -28
  8. basic_memory/cli/auth.py +277 -0
  9. basic_memory/cli/commands/cloud/__init__.py +5 -0
  10. basic_memory/cli/commands/cloud/api_client.py +112 -0
  11. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  12. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  13. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  14. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  15. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  16. basic_memory/cli/commands/command_utils.py +43 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +77 -60
  19. basic_memory/cli/commands/project.py +154 -152
  20. basic_memory/cli/commands/status.py +25 -22
  21. basic_memory/cli/commands/sync.py +45 -228
  22. basic_memory/cli/commands/tool.py +87 -16
  23. basic_memory/cli/main.py +1 -0
  24. basic_memory/config.py +131 -21
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +27 -8
  27. basic_memory/file_utils.py +37 -13
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/markdown/plugins.py +9 -7
  30. basic_memory/mcp/async_client.py +124 -14
  31. basic_memory/mcp/project_context.py +141 -0
  32. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  33. basic_memory/mcp/prompts/continue_conversation.py +17 -16
  34. basic_memory/mcp/prompts/recent_activity.py +116 -32
  35. basic_memory/mcp/prompts/search.py +13 -12
  36. basic_memory/mcp/prompts/utils.py +11 -4
  37. basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
  38. basic_memory/mcp/resources/project_info.py +27 -11
  39. basic_memory/mcp/server.py +0 -37
  40. basic_memory/mcp/tools/__init__.py +5 -6
  41. basic_memory/mcp/tools/build_context.py +67 -56
  42. basic_memory/mcp/tools/canvas.py +38 -26
  43. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  44. basic_memory/mcp/tools/delete_note.py +81 -47
  45. basic_memory/mcp/tools/edit_note.py +155 -138
  46. basic_memory/mcp/tools/list_directory.py +112 -99
  47. basic_memory/mcp/tools/move_note.py +181 -101
  48. basic_memory/mcp/tools/project_management.py +113 -277
  49. basic_memory/mcp/tools/read_content.py +91 -74
  50. basic_memory/mcp/tools/read_note.py +152 -115
  51. basic_memory/mcp/tools/recent_activity.py +471 -68
  52. basic_memory/mcp/tools/search.py +105 -92
  53. basic_memory/mcp/tools/sync_status.py +136 -130
  54. basic_memory/mcp/tools/utils.py +4 -0
  55. basic_memory/mcp/tools/view_note.py +44 -33
  56. basic_memory/mcp/tools/write_note.py +151 -90
  57. basic_memory/models/knowledge.py +12 -6
  58. basic_memory/models/project.py +6 -2
  59. basic_memory/repository/entity_repository.py +89 -82
  60. basic_memory/repository/relation_repository.py +13 -0
  61. basic_memory/repository/repository.py +18 -5
  62. basic_memory/repository/search_repository.py +46 -2
  63. basic_memory/schemas/__init__.py +6 -0
  64. basic_memory/schemas/base.py +39 -11
  65. basic_memory/schemas/cloud.py +46 -0
  66. basic_memory/schemas/memory.py +90 -21
  67. basic_memory/schemas/project_info.py +9 -10
  68. basic_memory/schemas/sync_report.py +48 -0
  69. basic_memory/services/context_service.py +25 -11
  70. basic_memory/services/directory_service.py +124 -3
  71. basic_memory/services/entity_service.py +100 -48
  72. basic_memory/services/initialization.py +30 -11
  73. basic_memory/services/project_service.py +101 -24
  74. basic_memory/services/search_service.py +16 -8
  75. basic_memory/sync/sync_service.py +173 -34
  76. basic_memory/sync/watch_service.py +101 -40
  77. basic_memory/utils.py +14 -4
  78. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
  79. basic_memory-0.15.1.dist-info/RECORD +146 -0
  80. basic_memory/mcp/project_session.py +0 -120
  81. basic_memory-0.14.4.dist-info/RECORD +0 -133
  82. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  83. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  84. {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
@@ -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,
@@ -1,28 +1,138 @@
1
- from httpx import ASGITransport, AsyncClient
1
+ from contextlib import asynccontextmanager, AbstractAsyncContextManager
2
+ from typing import AsyncIterator, Callable, Optional
3
+
4
+ from httpx import ASGITransport, AsyncClient, Timeout
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 HTTP transport
114
+ AsyncClient configured for either local ASGI or remote proxy
13
115
  """
14
116
  config_manager = ConfigManager()
15
- config = config_manager.load_config()
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
- # Create shared async client
28
- client = create_client()
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}']"