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.
- basic_memory/__init__.py +1 -1
- basic_memory/api/app.py +11 -3
- basic_memory/cli/app.py +12 -7
- basic_memory/cli/commands/mcp.py +18 -9
- basic_memory/cli/commands/sync.py +9 -8
- basic_memory/cli/commands/tool.py +28 -15
- basic_memory/cli/main.py +12 -44
- basic_memory/config.py +30 -6
- basic_memory/db.py +3 -1
- basic_memory/file_utils.py +3 -0
- basic_memory/markdown/entity_parser.py +16 -7
- basic_memory/markdown/utils.py +21 -13
- basic_memory/mcp/prompts/continue_conversation.py +4 -4
- basic_memory/mcp/prompts/search.py +2 -2
- basic_memory/mcp/server.py +29 -3
- basic_memory/mcp/tools/read_note.py +2 -3
- basic_memory/mcp/tools/search.py +64 -28
- basic_memory/mcp/tools/write_note.py +3 -1
- basic_memory/repository/repository.py +0 -4
- basic_memory/repository/search_repository.py +11 -11
- basic_memory/schemas/search.py +2 -2
- basic_memory/services/context_service.py +1 -1
- basic_memory/services/entity_service.py +10 -10
- basic_memory/services/file_service.py +1 -1
- basic_memory/services/initialization.py +143 -0
- basic_memory/services/link_resolver.py +8 -1
- basic_memory/services/search_service.py +3 -23
- basic_memory/sync/sync_service.py +120 -191
- basic_memory/sync/watch_service.py +49 -30
- basic_memory/utils.py +10 -2
- {basic_memory-0.11.0.dist-info → basic_memory-0.12.1.dist-info}/METADATA +42 -11
- {basic_memory-0.11.0.dist-info → basic_memory-0.12.1.dist-info}/RECORD +35 -34
- {basic_memory-0.11.0.dist-info → basic_memory-0.12.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.11.0.dist-info → basic_memory-0.12.1.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.11.0.dist-info → basic_memory-0.12.1.dist-info}/licenses/LICENSE +0 -0
basic_memory/__init__.py
CHANGED
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.
|
|
11
|
-
from basic_memory.
|
|
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
|
-
|
|
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")
|
basic_memory/cli/commands/mcp.py
CHANGED
|
@@ -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
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
187
|
+
# one time sync
|
|
187
188
|
logger.info("Running one-time sync")
|
|
188
|
-
knowledge_changes = await sync_service.sync(config.home
|
|
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
|
|
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
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
12
|
-
mcp,
|
|
8
|
+
import_chatgpt,
|
|
13
9
|
import_claude_conversations,
|
|
14
10
|
import_claude_projects,
|
|
15
|
-
|
|
16
|
-
|
|
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=
|
|
38
|
+
default=1000, description="Milliseconds to wait after changes before syncing", gt=0
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
-
|
|
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(
|
|
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
|
-
|
|
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=
|
|
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"
|
|
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(
|
|
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:
|
basic_memory/file_utils.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
basic_memory/markdown/utils.py
CHANGED
|
@@ -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
|
|
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
|
|
11
|
-
from basic_memory.
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
entity_metadata.pop(field, None)
|
|
89
92
|
|
|
90
|
-
# Create Post with ordered
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
basic_memory/mcp/server.py
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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:
|