basic-memory 0.11.0__py3-none-any.whl → 0.12.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 (35) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/api/app.py +11 -3
  3. basic_memory/cli/app.py +12 -7
  4. basic_memory/cli/commands/mcp.py +18 -9
  5. basic_memory/cli/commands/sync.py +9 -8
  6. basic_memory/cli/commands/tool.py +28 -15
  7. basic_memory/cli/main.py +12 -44
  8. basic_memory/config.py +30 -6
  9. basic_memory/db.py +3 -1
  10. basic_memory/file_utils.py +3 -0
  11. basic_memory/markdown/entity_parser.py +16 -7
  12. basic_memory/markdown/utils.py +21 -13
  13. basic_memory/mcp/prompts/continue_conversation.py +4 -4
  14. basic_memory/mcp/prompts/search.py +2 -2
  15. basic_memory/mcp/server.py +29 -3
  16. basic_memory/mcp/tools/read_note.py +2 -3
  17. basic_memory/mcp/tools/search.py +64 -28
  18. basic_memory/mcp/tools/write_note.py +3 -1
  19. basic_memory/repository/repository.py +0 -4
  20. basic_memory/repository/search_repository.py +11 -11
  21. basic_memory/schemas/search.py +2 -2
  22. basic_memory/services/context_service.py +1 -1
  23. basic_memory/services/entity_service.py +10 -10
  24. basic_memory/services/file_service.py +1 -1
  25. basic_memory/services/initialization.py +143 -0
  26. basic_memory/services/link_resolver.py +8 -1
  27. basic_memory/services/search_service.py +3 -23
  28. basic_memory/sync/sync_service.py +120 -191
  29. basic_memory/sync/watch_service.py +49 -30
  30. basic_memory/utils.py +10 -2
  31. {basic_memory-0.11.0.dist-info → basic_memory-0.12.1.dist-info}/METADATA +42 -11
  32. {basic_memory-0.11.0.dist-info → basic_memory-0.12.1.dist-info}/RECORD +35 -34
  33. {basic_memory-0.11.0.dist-info → basic_memory-0.12.1.dist-info}/WHEEL +0 -0
  34. {basic_memory-0.11.0.dist-info → basic_memory-0.12.1.dist-info}/entry_points.txt +0 -0
  35. {basic_memory-0.11.0.dist-info → basic_memory-0.12.1.dist-info}/licenses/LICENSE +0 -0
basic_memory/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
2
2
 
3
- __version__ = "0.11.0"
3
+ __version__ = "0.12.1"
basic_memory/api/app.py CHANGED
@@ -7,16 +7,24 @@ from fastapi.exception_handlers import http_exception_handler
7
7
  from loguru import logger
8
8
 
9
9
  from basic_memory import db
10
- from basic_memory.config import config as app_config
11
- from basic_memory.api.routers import knowledge, search, memory, resource, project_info
10
+ from basic_memory.api.routers import knowledge, memory, project_info, resource, search
11
+ from basic_memory.config import config as project_config
12
+ from basic_memory.services.initialization import initialize_app
12
13
 
13
14
 
14
15
  @asynccontextmanager
15
16
  async def lifespan(app: FastAPI): # pragma: no cover
16
17
  """Lifecycle manager for the FastAPI app."""
17
- await db.run_migrations(app_config)
18
+ # Initialize database and file sync services
19
+ watch_task = await initialize_app(project_config)
20
+
21
+ # proceed with startup
18
22
  yield
23
+
19
24
  logger.info("Shutting down Basic Memory API")
25
+ if watch_task:
26
+ watch_task.cancel()
27
+
20
28
  await db.shutdown_db()
21
29
 
22
30
 
basic_memory/cli/app.py CHANGED
@@ -1,18 +1,17 @@
1
- import asyncio
2
1
  from typing import Optional
3
2
 
4
3
  import typer
5
4
 
6
- from basic_memory import db
7
- from basic_memory.config import config
8
-
9
5
 
10
6
  def version_callback(value: bool) -> None:
11
7
  """Show version and exit."""
12
8
  if value: # pragma: no cover
13
9
  import basic_memory
10
+ from basic_memory.config import config
14
11
 
15
12
  typer.echo(f"Basic Memory version: {basic_memory.__version__}")
13
+ typer.echo(f"Current project: {config.project}")
14
+ typer.echo(f"Project path: {config.home}")
16
15
  raise typer.Exit()
17
16
 
18
17
 
@@ -21,11 +20,12 @@ app = typer.Typer(name="basic-memory")
21
20
 
22
21
  @app.callback()
23
22
  def app_callback(
23
+ ctx: typer.Context,
24
24
  project: Optional[str] = typer.Option(
25
25
  None,
26
26
  "--project",
27
27
  "-p",
28
- help="Specify which project to use",
28
+ help="Specify which project to use 1",
29
29
  envvar="BASIC_MEMORY_PROJECT",
30
30
  ),
31
31
  version: Optional[bool] = typer.Option(
@@ -38,6 +38,7 @@ def app_callback(
38
38
  ),
39
39
  ) -> None:
40
40
  """Basic Memory - Local-first personal knowledge management."""
41
+
41
42
  # We use the project option to set the BASIC_MEMORY_PROJECT environment variable
42
43
  # The config module will pick this up when loading
43
44
  if project: # pragma: no cover
@@ -57,9 +58,13 @@ def app_callback(
57
58
 
58
59
  config = new_config
59
60
 
61
+ # Run migrations for every command unless --version was specified
62
+ if not version and ctx.invoked_subcommand is not None:
63
+ from basic_memory.config import config
64
+ from basic_memory.services.initialization import ensure_initialize_database
65
+
66
+ ensure_initialize_database(config)
60
67
 
61
- # Run database migrations
62
- asyncio.run(db.run_migrations(config))
63
68
 
64
69
  # Register sub-command groups
65
70
  import_app = typer.Typer(help="Import data from various sources")
@@ -1,10 +1,7 @@
1
1
  """MCP server command."""
2
2
 
3
- from loguru import logger
4
-
5
3
  import basic_memory
6
4
  from basic_memory.cli.app import app
7
- from basic_memory.config import config
8
5
 
9
6
  # Import mcp instance
10
7
  from basic_memory.mcp.server import mcp as mcp_server # pragma: no cover
@@ -15,12 +12,24 @@ import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
15
12
 
16
13
  @app.command()
17
14
  def mcp(): # pragma: no cover
18
- """Run the MCP server for Claude Desktop integration."""
19
- home_dir = config.home
20
- project_name = config.project
15
+ """Run the MCP server"""
16
+ from basic_memory.config import config
17
+ import asyncio
18
+ from basic_memory.services.initialization import initialize_database
19
+
20
+ # First, run just the database migrations synchronously
21
+ asyncio.run(initialize_database(config))
22
+
23
+ # Load config to check if sync is enabled
24
+ from basic_memory.config import config_manager
25
+
26
+ basic_memory_config = config_manager.load_config()
27
+
28
+ if basic_memory_config.sync_changes:
29
+ # For now, we'll just log that sync will be handled by the MCP server
30
+ from loguru import logger
21
31
 
22
- logger.info(f"Starting Basic Memory MCP server {basic_memory.__version__}")
23
- logger.info(f"Project: {project_name}")
24
- logger.info(f"Project directory: {home_dir}")
32
+ logger.info("File sync will be handled by the MCP server")
25
33
 
34
+ # Start the MCP server
26
35
  mcp_server.run()
@@ -70,6 +70,7 @@ async def get_sync_service(): # pragma: no cover
70
70
 
71
71
  # Create sync service
72
72
  sync_service = SyncService(
73
+ config=config,
73
74
  entity_service=entity_service,
74
75
  entity_parser=entity_parser,
75
76
  entity_repository=entity_repository,
@@ -178,14 +179,14 @@ async def run_sync(verbose: bool = False, watch: bool = False, console_status: b
178
179
  )
179
180
 
180
181
  # full sync - no progress bars in watch mode
181
- await sync_service.sync(config.home, show_progress=False)
182
+ await sync_service.sync(config.home)
182
183
 
183
184
  # watch changes
184
185
  await watch_service.run() # pragma: no cover
185
186
  else:
186
- # one time sync - use progress bars for better UX
187
+ # one time sync
187
188
  logger.info("Running one-time sync")
188
- knowledge_changes = await sync_service.sync(config.home, show_progress=True)
189
+ knowledge_changes = await sync_service.sync(config.home)
189
190
 
190
191
  # Log results
191
192
  duration_ms = int((time.time() - start_time) * 1000)
@@ -236,11 +237,11 @@ def sync(
236
237
  if not isinstance(e, typer.Exit):
237
238
  logger.exception(
238
239
  "Sync command failed",
239
- project=config.project,
240
- error=str(e),
241
- error_type=type(e).__name__,
242
- watch_mode=watch,
243
- directory=str(config.home),
240
+ f"project={config.project},"
241
+ f"error={str(e)},"
242
+ f"error_type={type(e).__name__},"
243
+ f"watch_mode={watch},"
244
+ f"directory={str(config.home)}",
244
245
  )
245
246
  typer.echo(f"Error during sync: {e}", err=True)
246
247
  raise typer.Exit(1)
@@ -2,31 +2,29 @@
2
2
 
3
3
  import asyncio
4
4
  import sys
5
- from typing import Optional, List, Annotated
5
+ from typing import Annotated, List, Optional
6
6
 
7
7
  import typer
8
8
  from loguru import logger
9
9
  from rich import print as rprint
10
10
 
11
11
  from basic_memory.cli.app import app
12
- from basic_memory.mcp.tools import build_context as mcp_build_context
13
- from basic_memory.mcp.tools import read_note as mcp_read_note
14
- from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
15
- from basic_memory.mcp.tools import search_notes as mcp_search
16
- from basic_memory.mcp.tools import write_note as mcp_write_note
17
12
 
18
13
  # Import prompts
19
14
  from basic_memory.mcp.prompts.continue_conversation import (
20
15
  continue_conversation as mcp_continue_conversation,
21
16
  )
22
-
23
17
  from basic_memory.mcp.prompts.recent_activity import (
24
18
  recent_activity_prompt as recent_activity_prompt,
25
19
  )
26
-
20
+ from basic_memory.mcp.tools import build_context as mcp_build_context
21
+ from basic_memory.mcp.tools import read_note as mcp_read_note
22
+ from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
23
+ from basic_memory.mcp.tools import search_notes as mcp_search
24
+ from basic_memory.mcp.tools import write_note as mcp_write_note
27
25
  from basic_memory.schemas.base import TimeFrame
28
26
  from basic_memory.schemas.memory import MemoryUrl
29
- from basic_memory.schemas.search import SearchQuery, SearchItemType
27
+ from basic_memory.schemas.search import SearchItemType
30
28
 
31
29
  tool_app = typer.Typer()
32
30
  app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
@@ -198,13 +196,28 @@ def search_notes(
198
196
  raise typer.Abort()
199
197
 
200
198
  try:
201
- search_query = SearchQuery(
202
- permalink_match=query if permalink else None,
203
- text=query if not (permalink or title) else None,
204
- title=query if title else None,
205
- after_date=after_date,
199
+ if permalink and title: # pragma: no cover
200
+ typer.echo(
201
+ "Use either --permalink or --title, not both. Exiting.",
202
+ err=True,
203
+ )
204
+ raise typer.Exit(1)
205
+
206
+ # set search type
207
+ search_type = ("permalink" if permalink else None,)
208
+ search_type = ("permalink_match" if permalink and "*" in query else None,)
209
+ search_type = ("title" if title else None,)
210
+ search_type = "text" if search_type is None else search_type
211
+
212
+ results = asyncio.run(
213
+ mcp_search(
214
+ query,
215
+ search_type=search_type,
216
+ page=page,
217
+ after_date=after_date,
218
+ page_size=page_size,
219
+ )
206
220
  )
207
- results = asyncio.run(mcp_search(query=search_query, page=page, page_size=page_size))
208
221
  # Use json module for more controlled serialization
209
222
  import json
210
223
 
basic_memory/cli/main.py CHANGED
@@ -1,58 +1,26 @@
1
1
  """Main CLI entry point for basic-memory.""" # pragma: no cover
2
2
 
3
3
  from basic_memory.cli.app import app # pragma: no cover
4
- import typer
5
4
 
6
5
  # Register commands
7
6
  from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
8
- status,
9
- sync,
10
7
  db,
11
- import_memory_json,
12
- mcp,
8
+ import_chatgpt,
13
9
  import_claude_conversations,
14
10
  import_claude_projects,
15
- import_chatgpt,
16
- tool,
11
+ import_memory_json,
12
+ mcp,
17
13
  project,
14
+ status,
15
+ sync,
16
+ tool,
18
17
  )
19
-
20
-
21
- # Version command
22
- @app.callback(invoke_without_command=True)
23
- def main(
24
- ctx: typer.Context,
25
- project: str = typer.Option( # noqa
26
- "main",
27
- "--project",
28
- "-p",
29
- help="Specify which project to use",
30
- envvar="BASIC_MEMORY_PROJECT",
31
- ),
32
- version: bool = typer.Option(
33
- False,
34
- "--version",
35
- "-V",
36
- help="Show version information and exit.",
37
- is_eager=True,
38
- ),
39
- ):
40
- """Basic Memory - Local-first personal knowledge management system."""
41
- if version: # pragma: no cover
42
- from basic_memory import __version__
43
- from basic_memory.config import config
44
-
45
- typer.echo(f"Basic Memory v{__version__}")
46
- typer.echo(f"Current project: {config.project}")
47
- typer.echo(f"Project path: {config.home}")
48
- raise typer.Exit()
49
-
50
- # Handle project selection via environment variable
51
- if project:
52
- import os
53
-
54
- os.environ["BASIC_MEMORY_PROJECT"] = project
55
-
18
+ from basic_memory.config import config
19
+ from basic_memory.services.initialization import ensure_initialization
56
20
 
57
21
  if __name__ == "__main__": # pragma: no cover
22
+ # Run initialization if we are starting as a module
23
+ ensure_initialization(config)
24
+
25
+ # start the app
58
26
  app()
basic_memory/config.py CHANGED
@@ -35,10 +35,14 @@ class ProjectConfig(BaseSettings):
35
35
 
36
36
  # Watch service configuration
37
37
  sync_delay: int = Field(
38
- default=500, description="Milliseconds to wait after changes before syncing", gt=0
38
+ default=1000, description="Milliseconds to wait after changes before syncing", gt=0
39
39
  )
40
40
 
41
- log_level: str = "DEBUG"
41
+ # update permalinks on move
42
+ update_permalinks_on_move: bool = Field(
43
+ default=False,
44
+ description="Whether to update permalinks when files are moved or renamed. default (False)",
45
+ )
42
46
 
43
47
  model_config = SettingsConfigDict(
44
48
  env_prefix="BASIC_MEMORY_",
@@ -77,6 +81,18 @@ class BasicMemoryConfig(BaseSettings):
77
81
  description="Name of the default project to use",
78
82
  )
79
83
 
84
+ log_level: str = "INFO"
85
+
86
+ update_permalinks_on_move: bool = Field(
87
+ default=False,
88
+ description="Whether to update permalinks when files are moved or renamed. default (False)",
89
+ )
90
+
91
+ sync_changes: bool = Field(
92
+ default=True,
93
+ description="Whether to sync changes in real time. default (True)",
94
+ )
95
+
80
96
  model_config = SettingsConfigDict(
81
97
  env_prefix="BASIC_MEMORY_",
82
98
  extra="ignore",
@@ -194,9 +210,14 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
194
210
  "BASIC_MEMORY_PROJECT", project_name or config_manager.default_project
195
211
  )
196
212
 
213
+ update_permalinks_on_move = config_manager.load_config().update_permalinks_on_move
197
214
  try:
198
215
  project_path = config_manager.get_project_path(actual_project_name)
199
- return ProjectConfig(home=project_path, project=actual_project_name)
216
+ return ProjectConfig(
217
+ home=project_path,
218
+ project=actual_project_name,
219
+ update_permalinks_on_move=update_permalinks_on_move,
220
+ )
200
221
  except ValueError: # pragma: no cover
201
222
  logger.warning(f"Project '{actual_project_name}' not found, using default")
202
223
  project_path = config_manager.get_project_path(config_manager.default_project)
@@ -225,8 +246,10 @@ def get_process_name(): # pragma: no cover
225
246
  return "sync"
226
247
  elif "mcp" in sys.argv:
227
248
  return "mcp"
228
- else:
249
+ elif "cli" in sys.argv:
229
250
  return "cli"
251
+ else:
252
+ return "api"
230
253
 
231
254
 
232
255
  process_name = get_process_name()
@@ -234,6 +257,7 @@ process_name = get_process_name()
234
257
  # Global flag to track if logging has been set up
235
258
  _LOGGING_SETUP = False
236
259
 
260
+
237
261
  def setup_basic_memory_logging(): # pragma: no cover
238
262
  """Set up logging for basic-memory, ensuring it only happens once."""
239
263
  global _LOGGING_SETUP
@@ -245,12 +269,12 @@ def setup_basic_memory_logging(): # pragma: no cover
245
269
  setup_logging(
246
270
  env=config.env,
247
271
  home_dir=user_home, # Use user home for logs
248
- log_level=config.log_level,
272
+ log_level=config_manager.load_config().log_level,
249
273
  log_file=f"{DATA_DIR_NAME}/basic-memory-{process_name}.log",
250
274
  console=False,
251
275
  )
252
276
 
253
- logger.info(f"Starting Basic Memory {basic_memory.__version__} (Project: {config.project})")
277
+ logger.info(f"Basic Memory {basic_memory.__version__} (Project: {config.project})")
254
278
  _LOGGING_SETUP = True
255
279
 
256
280
 
basic_memory/db.py CHANGED
@@ -146,7 +146,9 @@ async def engine_session_factory(
146
146
  _session_maker = None
147
147
 
148
148
 
149
- async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.FILESYSTEM):
149
+ async def run_migrations(
150
+ app_config: ProjectConfig, database_type=DatabaseType.FILESYSTEM
151
+ ): # pragma: no cover
150
152
  """Run any pending alembic migrations."""
151
153
  logger.info("Running database migrations...")
152
154
  try:
@@ -104,6 +104,9 @@ def has_frontmatter(content: str) -> bool:
104
104
  Returns:
105
105
  True if content has valid frontmatter markers (---), False otherwise
106
106
  """
107
+ if not content:
108
+ return False
109
+
107
110
  content = content.strip()
108
111
  if not content.startswith("---"):
109
112
  return False
@@ -92,27 +92,36 @@ class EntityParser:
92
92
  async def parse_file(self, path: Path | str) -> EntityMarkdown:
93
93
  """Parse markdown file into EntityMarkdown."""
94
94
 
95
- absolute_path = self.base_path / path
95
+ # TODO move to api endpoint to check if absolute path was requested
96
+ # Check if the path is already absolute
97
+ if (
98
+ isinstance(path, Path)
99
+ and path.is_absolute()
100
+ or (isinstance(path, str) and Path(path).is_absolute())
101
+ ):
102
+ absolute_path = Path(path)
103
+ else:
104
+ absolute_path = self.base_path / path
105
+
96
106
  # Parse frontmatter and content using python-frontmatter
97
- post = frontmatter.load(str(absolute_path))
107
+ file_content = absolute_path.read_text()
108
+ return await self.parse_file_content(absolute_path, file_content)
98
109
 
110
+ async def parse_file_content(self, absolute_path, file_content):
111
+ post = frontmatter.loads(file_content)
99
112
  # Extract file stat info
100
113
  file_stats = absolute_path.stat()
101
-
102
114
  metadata = post.metadata
103
- metadata["title"] = post.metadata.get("title", absolute_path.name)
115
+ metadata["title"] = post.metadata.get("title", absolute_path.stem)
104
116
  metadata["type"] = post.metadata.get("type", "note")
105
117
  tags = parse_tags(post.metadata.get("tags", [])) # pyright: ignore
106
118
  if tags:
107
119
  metadata["tags"] = tags
108
-
109
120
  # frontmatter
110
121
  entity_frontmatter = EntityFrontmatter(
111
122
  metadata=post.metadata,
112
123
  )
113
-
114
124
  entity_content = parse(post.content)
115
-
116
125
  return EntityMarkdown(
117
126
  frontmatter=entity_frontmatter,
118
127
  content=post.content,
@@ -1,14 +1,14 @@
1
1
  """Utilities for converting between markdown and entity models."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import Optional, Any
4
+ from typing import Any, Optional
5
5
 
6
6
  from frontmatter import Post
7
7
 
8
- from basic_memory.file_utils import has_frontmatter, remove_frontmatter
8
+ from basic_memory.file_utils import has_frontmatter, remove_frontmatter, parse_frontmatter
9
9
  from basic_memory.markdown import EntityMarkdown
10
- from basic_memory.models import Entity, Observation as ObservationModel
11
- from basic_memory.utils import generate_permalink
10
+ from basic_memory.models import Entity
11
+ from basic_memory.models import Observation as ObservationModel
12
12
 
13
13
 
14
14
  def entity_model_from_markdown(
@@ -32,16 +32,13 @@ def entity_model_from_markdown(
32
32
  if not markdown.created or not markdown.modified: # pragma: no cover
33
33
  raise ValueError("Both created and modified dates are required in markdown")
34
34
 
35
- # Generate permalink if not provided
36
- permalink = markdown.frontmatter.permalink or generate_permalink(file_path)
37
-
38
35
  # Create or update entity
39
36
  model = entity or Entity()
40
37
 
41
38
  # Update basic fields
42
39
  model.title = markdown.frontmatter.title
43
40
  model.entity_type = markdown.frontmatter.type
44
- model.permalink = permalink
41
+ model.permalink = markdown.frontmatter.permalink
45
42
  model.file_path = str(file_path)
46
43
  model.content_type = "text/markdown"
47
44
  model.created_at = markdown.created
@@ -77,22 +74,33 @@ async def schema_to_markdown(schema: Any) -> Post:
77
74
  """
78
75
  # Extract content and metadata
79
76
  content = schema.content or ""
80
- frontmatter_metadata = dict(schema.entity_metadata or {})
77
+ entity_metadata = dict(schema.entity_metadata or {})
81
78
 
82
79
  # if the content contains frontmatter, remove it and merge
83
80
  if has_frontmatter(content):
81
+ content_frontmatter = parse_frontmatter(content)
84
82
  content = remove_frontmatter(content)
85
83
 
84
+ # Merge content frontmatter with entity metadata
85
+ # (entity_metadata takes precedence for conflicts)
86
+ content_frontmatter.update(entity_metadata)
87
+ entity_metadata = content_frontmatter
88
+
86
89
  # Remove special fields for ordered frontmatter
87
90
  for field in ["type", "title", "permalink"]:
88
- frontmatter_metadata.pop(field, None)
91
+ entity_metadata.pop(field, None)
89
92
 
90
- # Create Post with ordered fields
93
+ # Create Post with fields ordered by insert order
91
94
  post = Post(
92
95
  content,
93
96
  title=schema.title,
94
97
  type=schema.entity_type,
95
- permalink=schema.permalink,
96
- **frontmatter_metadata,
97
98
  )
99
+ # set the permalink if passed in
100
+ if schema.permalink:
101
+ post.metadata["permalink"] = schema.permalink
102
+
103
+ if entity_metadata:
104
+ post.metadata.update(entity_metadata)
105
+
98
106
  return post
@@ -5,19 +5,19 @@ providing context from previous interactions to maintain continuity.
5
5
  """
6
6
 
7
7
  from textwrap import dedent
8
- from typing import Optional, Annotated
8
+ from typing import Annotated, Optional
9
9
 
10
10
  from loguru import logger
11
11
  from pydantic import Field
12
12
 
13
- from basic_memory.mcp.prompts.utils import format_prompt_context, PromptContext, PromptContextItem
13
+ from basic_memory.mcp.prompts.utils import PromptContext, PromptContextItem, format_prompt_context
14
14
  from basic_memory.mcp.server import mcp
15
15
  from basic_memory.mcp.tools.build_context import build_context
16
16
  from basic_memory.mcp.tools.recent_activity import recent_activity
17
17
  from basic_memory.mcp.tools.search import search_notes
18
18
  from basic_memory.schemas.base import TimeFrame
19
19
  from basic_memory.schemas.memory import GraphContext
20
- from basic_memory.schemas.search import SearchQuery, SearchItemType
20
+ from basic_memory.schemas.search import SearchItemType
21
21
 
22
22
 
23
23
  @mcp.prompt(
@@ -48,7 +48,7 @@ async def continue_conversation(
48
48
  # If topic provided, search for it
49
49
  if topic:
50
50
  search_results = await search_notes(
51
- SearchQuery(text=topic, after_date=timeframe, types=[SearchItemType.ENTITY])
51
+ query=topic, after_date=timeframe, entity_types=[SearchItemType.ENTITY]
52
52
  )
53
53
 
54
54
  # Build context from results
@@ -12,7 +12,7 @@ from pydantic import Field
12
12
  from basic_memory.mcp.server import mcp
13
13
  from basic_memory.mcp.tools.search import search_notes as search_tool
14
14
  from basic_memory.schemas.base import TimeFrame
15
- from basic_memory.schemas.search import SearchQuery, SearchResponse
15
+ from basic_memory.schemas.search import SearchResponse
16
16
 
17
17
 
18
18
  @mcp.prompt(
@@ -40,7 +40,7 @@ async def search_prompt(
40
40
  """
41
41
  logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}")
42
42
 
43
- search_results = await search_tool(SearchQuery(text=query, after_date=timeframe))
43
+ search_results = await search_tool(query=query, after_date=timeframe)
44
44
  return format_search_results(query, search_results, timeframe)
45
45
 
46
46
 
@@ -1,11 +1,37 @@
1
1
  """Enhanced FastMCP server instance for Basic Memory."""
2
2
 
3
+ import asyncio
4
+ from contextlib import asynccontextmanager
5
+ from typing import AsyncIterator, Optional
6
+
3
7
  from mcp.server.fastmcp import FastMCP
4
- from mcp.server.fastmcp.utilities.logging import configure_logging
8
+ from mcp.server.fastmcp.utilities.logging import configure_logging as mcp_configure_logging
9
+ from dataclasses import dataclass
10
+
11
+ from basic_memory.config import config as project_config
12
+ from basic_memory.services.initialization import initialize_app
5
13
 
6
14
  # mcp console logging
7
- configure_logging(level="INFO")
15
+ mcp_configure_logging(level="ERROR")
16
+
17
+
18
+ @dataclass
19
+ class AppContext:
20
+ watch_task: Optional[asyncio.Task]
21
+
22
+
23
+ @asynccontextmanager
24
+ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma: no cover
25
+ """Manage application lifecycle with type-safe context"""
26
+ # Initialize on startup
27
+ watch_task = await initialize_app(project_config)
28
+ try:
29
+ yield AppContext(watch_task=watch_task)
30
+ finally:
31
+ # Cleanup on shutdown
32
+ if watch_task:
33
+ watch_task.cancel()
8
34
 
9
35
 
10
36
  # Create the shared server instance
11
- mcp = FastMCP("Basic Memory")
37
+ mcp = FastMCP("Basic Memory", log_level="ERROR", lifespan=app_lifespan)
@@ -9,7 +9,6 @@ from basic_memory.mcp.server import mcp
9
9
  from basic_memory.mcp.tools.search import search_notes
10
10
  from basic_memory.mcp.tools.utils import call_get
11
11
  from basic_memory.schemas.memory import memory_url_path
12
- from basic_memory.schemas.search import SearchQuery
13
12
 
14
13
 
15
14
  @mcp.tool(
@@ -63,7 +62,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
63
62
 
64
63
  # Fallback 1: Try title search via API
65
64
  logger.info(f"Search title for: {identifier}")
66
- title_results = await search_notes(SearchQuery(title=identifier))
65
+ title_results = await search_notes(query=identifier, search_type="title")
67
66
 
68
67
  if title_results and title_results.results:
69
68
  result = title_results.results[0] # Get the first/best match
@@ -87,7 +86,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
87
86
 
88
87
  # Fallback 2: Text search as a last resort
89
88
  logger.info(f"Title search failed, trying text search for: {identifier}")
90
- text_results = await search_notes(SearchQuery(text=identifier))
89
+ text_results = await search_notes(query=identifier, search_type="text")
91
90
 
92
91
  # We didn't find a direct match, construct a helpful error message
93
92
  if not text_results or not text_results.results: