basic-memory 0.16.1__py3-none-any.whl → 0.17.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/env.py +112 -26
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +45 -24
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/knowledge_router.py +17 -5
- basic_memory/api/routers/project_router.py +68 -14
- basic_memory/api/routers/resource_router.py +37 -27
- basic_memory/api/routers/utils.py +53 -14
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +43 -7
- basic_memory/cli/auth.py +27 -4
- basic_memory/cli/commands/__init__.py +3 -1
- basic_memory/cli/commands/cloud/api_client.py +20 -5
- basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
- basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
- basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
- basic_memory/cli/commands/cloud/upload.py +10 -3
- basic_memory/cli/commands/command_utils.py +52 -4
- basic_memory/cli/commands/db.py +78 -19
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +12 -8
- basic_memory/cli/commands/import_claude_conversations.py +12 -8
- basic_memory/cli/commands/import_claude_projects.py +12 -8
- basic_memory/cli/commands/import_memory_json.py +12 -8
- basic_memory/cli/commands/mcp.py +8 -26
- basic_memory/cli/commands/project.py +22 -9
- basic_memory/cli/commands/status.py +3 -2
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +7 -0
- basic_memory/config.py +177 -77
- basic_memory/db.py +183 -77
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +14 -409
- basic_memory/file_utils.py +212 -3
- basic_memory/ignore_utils.py +5 -5
- basic_memory/importers/base.py +40 -19
- basic_memory/importers/chatgpt_importer.py +17 -4
- basic_memory/importers/claude_conversations_importer.py +27 -12
- basic_memory/importers/claude_projects_importer.py +50 -14
- basic_memory/importers/memory_json_importer.py +36 -16
- basic_memory/importers/utils.py +5 -2
- basic_memory/markdown/entity_parser.py +62 -23
- basic_memory/markdown/markdown_processor.py +67 -4
- basic_memory/markdown/plugins.py +4 -2
- basic_memory/markdown/utils.py +10 -1
- basic_memory/mcp/async_client.py +1 -0
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +47 -33
- basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
- basic_memory/mcp/prompts/recent_activity.py +2 -2
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/server.py +58 -0
- basic_memory/mcp/tools/build_context.py +14 -14
- basic_memory/mcp/tools/canvas.py +34 -12
- basic_memory/mcp/tools/chatgpt_tools.py +4 -1
- basic_memory/mcp/tools/delete_note.py +31 -7
- basic_memory/mcp/tools/edit_note.py +14 -9
- basic_memory/mcp/tools/list_directory.py +7 -17
- basic_memory/mcp/tools/move_note.py +35 -31
- basic_memory/mcp/tools/project_management.py +29 -25
- basic_memory/mcp/tools/read_content.py +13 -3
- basic_memory/mcp/tools/read_note.py +24 -14
- basic_memory/mcp/tools/recent_activity.py +32 -38
- basic_memory/mcp/tools/search.py +17 -10
- basic_memory/mcp/tools/utils.py +28 -0
- basic_memory/mcp/tools/view_note.py +2 -1
- basic_memory/mcp/tools/write_note.py +37 -14
- basic_memory/models/knowledge.py +15 -2
- basic_memory/models/project.py +7 -1
- basic_memory/models/search.py +58 -2
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/entity_repository.py +210 -3
- basic_memory/repository/observation_repository.py +1 -0
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_repository.py +38 -1
- basic_memory/repository/relation_repository.py +58 -2
- basic_memory/repository/repository.py +1 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +77 -615
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/base.py +36 -6
- basic_memory/schemas/directory.py +2 -1
- basic_memory/schemas/memory.py +9 -2
- basic_memory/schemas/project_info.py +2 -0
- basic_memory/schemas/response.py +84 -27
- basic_memory/schemas/search.py +5 -0
- basic_memory/schemas/sync_report.py +1 -1
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/context_service.py +219 -43
- basic_memory/services/directory_service.py +26 -11
- basic_memory/services/entity_service.py +68 -33
- basic_memory/services/file_service.py +131 -16
- basic_memory/services/initialization.py +51 -26
- basic_memory/services/link_resolver.py +1 -0
- basic_memory/services/project_service.py +68 -43
- basic_memory/services/search_service.py +75 -16
- basic_memory/sync/__init__.py +2 -1
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +135 -115
- basic_memory/sync/watch_service.py +32 -12
- basic_memory/telemetry.py +249 -0
- basic_memory/utils.py +96 -75
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- basic_memory-0.16.1.dist-info/RECORD +0 -148
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -10,7 +10,7 @@ from basic_memory.mcp.async_client import get_client
|
|
|
10
10
|
from basic_memory.mcp.project_context import get_active_project
|
|
11
11
|
from basic_memory.mcp.server import mcp
|
|
12
12
|
from basic_memory.mcp.tools.search import search_notes
|
|
13
|
-
from basic_memory.
|
|
13
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
14
14
|
from basic_memory.schemas.memory import memory_url_path
|
|
15
15
|
from basic_memory.utils import validate_project_path
|
|
16
16
|
|
|
@@ -77,6 +77,7 @@ async def read_note(
|
|
|
77
77
|
If the exact note isn't found, this tool provides helpful suggestions
|
|
78
78
|
including related notes, search commands, and note creation templates.
|
|
79
79
|
"""
|
|
80
|
+
track_mcp_tool("read_note")
|
|
80
81
|
async with get_client() as client:
|
|
81
82
|
# Get and validate the project
|
|
82
83
|
active_project = await get_active_project(client, project, context)
|
|
@@ -97,23 +98,32 @@ async def read_note(
|
|
|
97
98
|
)
|
|
98
99
|
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# Get the file via REST API - first try direct permalink lookup
|
|
101
|
+
# Get the file via REST API - first try direct identifier resolution
|
|
103
102
|
entity_path = memory_url_path(identifier)
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
logger.info(
|
|
104
|
+
f"Attempting to read note from Project: {active_project.name} identifier: {entity_path}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Import here to avoid circular import
|
|
108
|
+
from basic_memory.mcp.clients import KnowledgeClient, ResourceClient
|
|
109
|
+
|
|
110
|
+
# Use typed clients for API calls
|
|
111
|
+
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
|
112
|
+
resource_client = ResourceClient(client, active_project.external_id)
|
|
106
113
|
|
|
107
114
|
try:
|
|
108
|
-
# Try
|
|
109
|
-
|
|
115
|
+
# Try to resolve identifier to entity ID
|
|
116
|
+
entity_id = await knowledge_client.resolve_entity(entity_path)
|
|
117
|
+
|
|
118
|
+
# Fetch content using entity ID
|
|
119
|
+
response = await resource_client.read(entity_id, page=page, page_size=page_size)
|
|
110
120
|
|
|
111
121
|
# If successful, return the content
|
|
112
122
|
if response.status_code == 200:
|
|
113
123
|
logger.info("Returning read_note result from resource: {path}", path=entity_path)
|
|
114
124
|
return response.text
|
|
115
125
|
except Exception as e: # pragma: no cover
|
|
116
|
-
logger.info(f"Direct lookup failed for '{
|
|
126
|
+
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
|
|
117
127
|
# Continue to fallback methods
|
|
118
128
|
|
|
119
129
|
# Fallback 1: Try title search via API
|
|
@@ -127,11 +137,11 @@ async def read_note(
|
|
|
127
137
|
result = title_results.results[0] # Get the first/best match
|
|
128
138
|
if result.permalink:
|
|
129
139
|
try:
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
)
|
|
140
|
+
# Resolve the permalink to entity ID
|
|
141
|
+
entity_id = await knowledge_client.resolve_entity(result.permalink)
|
|
142
|
+
|
|
143
|
+
# Fetch content using the entity ID
|
|
144
|
+
response = await resource_client.read(entity_id, page=page, page_size=page_size)
|
|
135
145
|
|
|
136
146
|
if response.status_code == 200:
|
|
137
147
|
logger.info(f"Found note by title search: {result.permalink}")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Recent activity tool for Basic Memory MCP server."""
|
|
2
2
|
|
|
3
|
+
from datetime import timezone
|
|
3
4
|
from typing import List, Union, Optional
|
|
4
5
|
|
|
5
6
|
from loguru import logger
|
|
@@ -9,6 +10,7 @@ from basic_memory.mcp.async_client import get_client
|
|
|
9
10
|
from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter
|
|
10
11
|
from basic_memory.mcp.server import mcp
|
|
11
12
|
from basic_memory.mcp.tools.utils import call_get
|
|
13
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
12
14
|
from basic_memory.schemas.base import TimeFrame
|
|
13
15
|
from basic_memory.schemas.memory import (
|
|
14
16
|
GraphContext,
|
|
@@ -98,6 +100,7 @@ async def recent_activity(
|
|
|
98
100
|
- For focused queries, consider using build_context with a specific URI
|
|
99
101
|
- Max timeframe is 1 year in the past
|
|
100
102
|
"""
|
|
103
|
+
track_mcp_tool("recent_activity")
|
|
101
104
|
async with get_client() as client:
|
|
102
105
|
# Build common parameters for API calls
|
|
103
106
|
params = {
|
|
@@ -133,7 +136,8 @@ async def recent_activity(
|
|
|
133
136
|
params["type"] = [t.value for t in validated_types] # pyright: ignore
|
|
134
137
|
|
|
135
138
|
# Resolve project parameter using the three-tier hierarchy
|
|
136
|
-
|
|
139
|
+
# allow_discovery=True enables Discovery Mode, so a project is not required
|
|
140
|
+
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
|
|
137
141
|
|
|
138
142
|
if resolved_project is None:
|
|
139
143
|
# Discovery Mode: Get activity across all projects
|
|
@@ -193,33 +197,7 @@ async def recent_activity(
|
|
|
193
197
|
# Generate guidance for the assistant
|
|
194
198
|
guidance_lines = ["\n" + "─" * 40]
|
|
195
199
|
|
|
196
|
-
if
|
|
197
|
-
guidance_lines.extend(
|
|
198
|
-
[
|
|
199
|
-
f"Suggested project: '{most_active_project}' (most active with {most_active_count} items)",
|
|
200
|
-
f"Ask user: 'Should I use {most_active_project} for this task, or would you prefer a different project?'",
|
|
201
|
-
]
|
|
202
|
-
)
|
|
203
|
-
elif active_projects > 0:
|
|
204
|
-
# Has activity but no clear most active project
|
|
205
|
-
active_project_names = [
|
|
206
|
-
name for name, activity in projects_activity.items() if activity.item_count > 0
|
|
207
|
-
]
|
|
208
|
-
if len(active_project_names) == 1:
|
|
209
|
-
guidance_lines.extend(
|
|
210
|
-
[
|
|
211
|
-
f"Suggested project: '{active_project_names[0]}' (only active project)",
|
|
212
|
-
f"Ask user: 'Should I use {active_project_names[0]} for this task?'",
|
|
213
|
-
]
|
|
214
|
-
)
|
|
215
|
-
else:
|
|
216
|
-
guidance_lines.extend(
|
|
217
|
-
[
|
|
218
|
-
f"Multiple active projects found: {', '.join(active_project_names)}",
|
|
219
|
-
"Ask user: 'Which project should I use for this task?'",
|
|
220
|
-
]
|
|
221
|
-
)
|
|
222
|
-
else:
|
|
200
|
+
if active_projects == 0:
|
|
223
201
|
# No recent activity
|
|
224
202
|
guidance_lines.extend(
|
|
225
203
|
[
|
|
@@ -227,6 +205,23 @@ async def recent_activity(
|
|
|
227
205
|
"Consider: Ask which project to use or if they want to create a new one.",
|
|
228
206
|
]
|
|
229
207
|
)
|
|
208
|
+
else:
|
|
209
|
+
# At least one project has activity: suggest the most active project.
|
|
210
|
+
suggested_project = most_active_project or next(
|
|
211
|
+
(name for name, activity in projects_activity.items() if activity.item_count > 0),
|
|
212
|
+
None,
|
|
213
|
+
)
|
|
214
|
+
if suggested_project:
|
|
215
|
+
suffix = (
|
|
216
|
+
f"(most active with {most_active_count} items)" if most_active_count > 0 else ""
|
|
217
|
+
)
|
|
218
|
+
guidance_lines.append(f"Suggested project: '{suggested_project}' {suffix}".strip())
|
|
219
|
+
if active_projects == 1:
|
|
220
|
+
guidance_lines.append(f"Ask user: 'Should I use {suggested_project} for this task?'")
|
|
221
|
+
else:
|
|
222
|
+
guidance_lines.append(
|
|
223
|
+
f"Ask user: 'Should I use {suggested_project} for this task, or would you prefer a different project?'"
|
|
224
|
+
)
|
|
230
225
|
|
|
231
226
|
guidance_lines.extend(
|
|
232
227
|
[
|
|
@@ -247,11 +242,10 @@ async def recent_activity(
|
|
|
247
242
|
)
|
|
248
243
|
|
|
249
244
|
active_project = await get_active_project(client, resolved_project, context)
|
|
250
|
-
project_url = active_project.project_url
|
|
251
245
|
|
|
252
246
|
response = await call_get(
|
|
253
247
|
client,
|
|
254
|
-
f"{
|
|
248
|
+
f"/v2/projects/{active_project.external_id}/memory/recent",
|
|
255
249
|
params=params,
|
|
256
250
|
)
|
|
257
251
|
activity_data = GraphContext.model_validate(response.json())
|
|
@@ -274,10 +268,9 @@ async def _get_project_activity(
|
|
|
274
268
|
Returns:
|
|
275
269
|
ProjectActivity with activity data or empty activity on error
|
|
276
270
|
"""
|
|
277
|
-
project_url = f"/{project_info.permalink}"
|
|
278
271
|
activity_response = await call_get(
|
|
279
272
|
client,
|
|
280
|
-
f"{
|
|
273
|
+
f"/v2/projects/{project_info.external_id}/memory/recent",
|
|
281
274
|
params=params,
|
|
282
275
|
)
|
|
283
276
|
activity = GraphContext.model_validate(activity_response.json())
|
|
@@ -289,12 +282,13 @@ async def _get_project_activity(
|
|
|
289
282
|
for result in activity.results:
|
|
290
283
|
if result.primary_result.created_at:
|
|
291
284
|
current_time = result.primary_result.created_at
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
285
|
+
if current_time.tzinfo is None:
|
|
286
|
+
current_time = current_time.replace(tzinfo=timezone.utc)
|
|
287
|
+
|
|
288
|
+
if last_activity is None:
|
|
289
|
+
last_activity = current_time
|
|
290
|
+
else:
|
|
291
|
+
if current_time > last_activity:
|
|
298
292
|
last_activity = current_time
|
|
299
293
|
|
|
300
294
|
# Extract folder from file_path
|
basic_memory/mcp/tools/search.py
CHANGED
|
@@ -9,7 +9,7 @@ from fastmcp import Context
|
|
|
9
9
|
from basic_memory.mcp.async_client import get_client
|
|
10
10
|
from basic_memory.mcp.project_context import get_active_project
|
|
11
11
|
from basic_memory.mcp.server import mcp
|
|
12
|
-
from basic_memory.
|
|
12
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
13
13
|
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
|
|
14
14
|
|
|
15
15
|
|
|
@@ -205,8 +205,8 @@ async def search_notes(
|
|
|
205
205
|
page: int = 1,
|
|
206
206
|
page_size: int = 10,
|
|
207
207
|
search_type: str = "text",
|
|
208
|
-
types: List[str] =
|
|
209
|
-
entity_types: List[str] =
|
|
208
|
+
types: List[str] | None = None,
|
|
209
|
+
entity_types: List[str] | None = None,
|
|
210
210
|
after_date: Optional[str] = None,
|
|
211
211
|
context: Context | None = None,
|
|
212
212
|
) -> SearchResponse | str:
|
|
@@ -330,6 +330,11 @@ async def search_notes(
|
|
|
330
330
|
# Explicit project specification
|
|
331
331
|
results = await search_notes("project planning", project="my-project")
|
|
332
332
|
"""
|
|
333
|
+
track_mcp_tool("search_notes")
|
|
334
|
+
# Avoid mutable-default-argument footguns. Treat None as "no filter".
|
|
335
|
+
types = types or []
|
|
336
|
+
entity_types = entity_types or []
|
|
337
|
+
|
|
333
338
|
# Create a SearchQuery object based on the parameters
|
|
334
339
|
search_query = SearchQuery()
|
|
335
340
|
|
|
@@ -355,18 +360,20 @@ async def search_notes(
|
|
|
355
360
|
|
|
356
361
|
async with get_client() as client:
|
|
357
362
|
active_project = await get_active_project(client, project, context)
|
|
358
|
-
project_url = active_project.project_url
|
|
359
363
|
|
|
360
364
|
logger.info(f"Searching for {search_query} in project {active_project.name}")
|
|
361
365
|
|
|
362
366
|
try:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
367
|
+
# Import here to avoid circular import (tools → clients → utils → tools)
|
|
368
|
+
from basic_memory.mcp.clients import SearchClient
|
|
369
|
+
|
|
370
|
+
# Use typed SearchClient for API calls
|
|
371
|
+
search_client = SearchClient(client, active_project.external_id)
|
|
372
|
+
result = await search_client.search(
|
|
373
|
+
search_query.model_dump(),
|
|
374
|
+
page=page,
|
|
375
|
+
page_size=page_size,
|
|
368
376
|
)
|
|
369
|
-
result = SearchResponse.model_validate(response.json())
|
|
370
377
|
|
|
371
378
|
# Check if we got no results and provide helpful guidance
|
|
372
379
|
if not result.results:
|
basic_memory/mcp/tools/utils.py
CHANGED
|
@@ -435,6 +435,34 @@ async def call_post(
|
|
|
435
435
|
raise ToolError(error_message) from e
|
|
436
436
|
|
|
437
437
|
|
|
438
|
+
async def resolve_entity_id(client: AsyncClient, project_external_id: str, identifier: str) -> str:
|
|
439
|
+
"""Resolve a string identifier to an entity external_id using the v2 API.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
client: HTTP client for API calls
|
|
443
|
+
project_external_id: Project external ID (UUID)
|
|
444
|
+
identifier: The identifier to resolve (permalink, title, or path)
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
The resolved entity external_id (UUID)
|
|
448
|
+
|
|
449
|
+
Raises:
|
|
450
|
+
ToolError: If the identifier cannot be resolved
|
|
451
|
+
"""
|
|
452
|
+
try:
|
|
453
|
+
response = await call_post(
|
|
454
|
+
client, f"/v2/projects/{project_external_id}/knowledge/resolve", json={"identifier": identifier}
|
|
455
|
+
)
|
|
456
|
+
data = response.json()
|
|
457
|
+
return data["external_id"]
|
|
458
|
+
except HTTPStatusError as e:
|
|
459
|
+
if e.response.status_code == 404: # pragma: no cover
|
|
460
|
+
raise ToolError(f"Entity not found: '{identifier}'") # pragma: no cover
|
|
461
|
+
raise ToolError(f"Error resolving identifier '{identifier}': {e}") # pragma: no cover
|
|
462
|
+
except Exception as e:
|
|
463
|
+
raise ToolError(f"Unexpected error resolving identifier '{identifier}': {e}") # pragma: no cover
|
|
464
|
+
|
|
465
|
+
|
|
438
466
|
async def call_delete(
|
|
439
467
|
client: AsyncClient,
|
|
440
468
|
url: URL | str,
|
|
@@ -8,6 +8,7 @@ from fastmcp import Context
|
|
|
8
8
|
|
|
9
9
|
from basic_memory.mcp.server import mcp
|
|
10
10
|
from basic_memory.mcp.tools.read_note import read_note
|
|
11
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@mcp.tool(
|
|
@@ -54,7 +55,7 @@ async def view_note(
|
|
|
54
55
|
HTTPError: If project doesn't exist or is inaccessible
|
|
55
56
|
SecurityError: If identifier attempts path traversal
|
|
56
57
|
"""
|
|
57
|
-
|
|
58
|
+
track_mcp_tool("view_note")
|
|
58
59
|
logger.info(f"Viewing note: {identifier} in project: {project}")
|
|
59
60
|
|
|
60
61
|
# Call the existing read_note logic
|
|
@@ -7,8 +7,7 @@ from loguru import logger
|
|
|
7
7
|
from basic_memory.mcp.async_client import get_client
|
|
8
8
|
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
|
9
9
|
from basic_memory.mcp.server import mcp
|
|
10
|
-
from basic_memory.
|
|
11
|
-
from basic_memory.schemas import EntityResponse
|
|
10
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
12
11
|
from fastmcp import Context
|
|
13
12
|
from basic_memory.schemas.base import Entity
|
|
14
13
|
from basic_memory.utils import parse_tags, validate_project_path
|
|
@@ -116,6 +115,7 @@ async def write_note(
|
|
|
116
115
|
HTTPError: If project doesn't exist or is inaccessible
|
|
117
116
|
SecurityError: If folder path attempts path traversal
|
|
118
117
|
"""
|
|
118
|
+
track_mcp_tool("write_note")
|
|
119
119
|
async with get_client() as client:
|
|
120
120
|
logger.info(
|
|
121
121
|
f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
|
|
@@ -150,16 +150,39 @@ async def write_note(
|
|
|
150
150
|
content=content,
|
|
151
151
|
entity_metadata=metadata,
|
|
152
152
|
)
|
|
153
|
-
project_url = active_project.permalink
|
|
154
153
|
|
|
155
|
-
#
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
#
|
|
162
|
-
|
|
154
|
+
# Import here to avoid circular import
|
|
155
|
+
from basic_memory.mcp.clients import KnowledgeClient
|
|
156
|
+
|
|
157
|
+
# Use typed KnowledgeClient for API calls
|
|
158
|
+
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
|
159
|
+
|
|
160
|
+
# Try to create the entity first (optimistic create)
|
|
161
|
+
logger.debug(f"Attempting to create entity permalink={entity.permalink}")
|
|
162
|
+
action = "Created" # Default to created
|
|
163
|
+
try:
|
|
164
|
+
result = await knowledge_client.create_entity(entity.model_dump())
|
|
165
|
+
action = "Created"
|
|
166
|
+
except Exception as e:
|
|
167
|
+
# If creation failed due to conflict (already exists), try to update
|
|
168
|
+
if (
|
|
169
|
+
"409" in str(e)
|
|
170
|
+
or "conflict" in str(e).lower()
|
|
171
|
+
or "already exists" in str(e).lower()
|
|
172
|
+
):
|
|
173
|
+
logger.debug(f"Entity exists, updating instead permalink={entity.permalink}")
|
|
174
|
+
try:
|
|
175
|
+
if not entity.permalink:
|
|
176
|
+
raise ValueError("Entity permalink is required for updates") # pragma: no cover
|
|
177
|
+
entity_id = await knowledge_client.resolve_entity(entity.permalink)
|
|
178
|
+
result = await knowledge_client.update_entity(entity_id, entity.model_dump())
|
|
179
|
+
action = "Updated"
|
|
180
|
+
except Exception as update_error: # pragma: no cover
|
|
181
|
+
# Re-raise the original error if update also fails
|
|
182
|
+
raise e from update_error # pragma: no cover
|
|
183
|
+
else:
|
|
184
|
+
# Re-raise if it's not a conflict error
|
|
185
|
+
raise # pragma: no cover
|
|
163
186
|
summary = [
|
|
164
187
|
f"# {action} note",
|
|
165
188
|
f"project: {active_project.name}",
|
|
@@ -201,7 +224,7 @@ async def write_note(
|
|
|
201
224
|
|
|
202
225
|
# Log the response with structured data
|
|
203
226
|
logger.info(
|
|
204
|
-
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved}
|
|
227
|
+
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved}"
|
|
205
228
|
)
|
|
206
|
-
|
|
207
|
-
return add_project_metadata(
|
|
229
|
+
summary_result = "\n".join(summary)
|
|
230
|
+
return add_project_metadata(summary_result, active_project.name)
|
basic_memory/models/knowledge.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Knowledge graph models."""
|
|
2
2
|
|
|
3
|
+
import uuid
|
|
3
4
|
from datetime import datetime
|
|
4
5
|
from basic_memory.utils import ensure_timezone_aware
|
|
5
6
|
from typing import Optional
|
|
@@ -38,6 +39,7 @@ class Entity(Base):
|
|
|
38
39
|
# Regular indexes
|
|
39
40
|
Index("ix_entity_type", "entity_type"),
|
|
40
41
|
Index("ix_entity_title", "title"),
|
|
42
|
+
Index("ix_entity_external_id", "external_id", unique=True),
|
|
41
43
|
Index("ix_entity_created_at", "created_at"), # For timeline queries
|
|
42
44
|
Index("ix_entity_updated_at", "updated_at"), # For timeline queries
|
|
43
45
|
Index("ix_entity_project_id", "project_id"), # For project filtering
|
|
@@ -59,6 +61,10 @@ class Entity(Base):
|
|
|
59
61
|
|
|
60
62
|
# Core identity
|
|
61
63
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
64
|
+
# External UUID for API references - stable identifier that won't change
|
|
65
|
+
external_id: Mapped[str] = mapped_column(
|
|
66
|
+
String, unique=True, default=lambda: str(uuid.uuid4())
|
|
67
|
+
)
|
|
62
68
|
title: Mapped[str] = mapped_column(String)
|
|
63
69
|
entity_type: Mapped[str] = mapped_column(String)
|
|
64
70
|
entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
|
@@ -129,7 +135,7 @@ class Entity(Base):
|
|
|
129
135
|
return value
|
|
130
136
|
|
|
131
137
|
def __repr__(self) -> str:
|
|
132
|
-
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
|
|
138
|
+
return f"Entity(id={self.id}, external_id='{self.external_id}', name='{self.title}', type='{self.entity_type}', checksum='{self.checksum}')"
|
|
133
139
|
|
|
134
140
|
|
|
135
141
|
class Observation(Base):
|
|
@@ -145,6 +151,7 @@ class Observation(Base):
|
|
|
145
151
|
)
|
|
146
152
|
|
|
147
153
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
154
|
+
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
|
|
148
155
|
entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
|
|
149
156
|
content: Mapped[str] = mapped_column(Text)
|
|
150
157
|
category: Mapped[str] = mapped_column(String, nullable=False, default="note")
|
|
@@ -162,9 +169,14 @@ class Observation(Base):
|
|
|
162
169
|
|
|
163
170
|
We can construct these because observations are always defined in
|
|
164
171
|
and owned by a single entity.
|
|
172
|
+
|
|
173
|
+
Content is truncated to 200 chars to stay under PostgreSQL's
|
|
174
|
+
btree index limit of 2704 bytes.
|
|
165
175
|
"""
|
|
176
|
+
# Truncate content to avoid exceeding PostgreSQL's btree index limit
|
|
177
|
+
content_for_permalink = self.content[:200] if len(self.content) > 200 else self.content
|
|
166
178
|
return generate_permalink(
|
|
167
|
-
f"{self.entity.permalink}/observations/{self.category}/{
|
|
179
|
+
f"{self.entity.permalink}/observations/{self.category}/{content_for_permalink}"
|
|
168
180
|
)
|
|
169
181
|
|
|
170
182
|
def __repr__(self) -> str: # pragma: no cover
|
|
@@ -186,6 +198,7 @@ class Relation(Base):
|
|
|
186
198
|
)
|
|
187
199
|
|
|
188
200
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
201
|
+
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), index=True)
|
|
189
202
|
from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
|
|
190
203
|
to_id: Mapped[Optional[int]] = mapped_column(
|
|
191
204
|
Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True
|
basic_memory/models/project.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Project model for Basic Memory."""
|
|
2
2
|
|
|
3
|
+
import uuid
|
|
3
4
|
from datetime import datetime, UTC
|
|
4
5
|
from typing import Optional
|
|
5
6
|
|
|
@@ -32,6 +33,7 @@ class Project(Base):
|
|
|
32
33
|
# Regular indexes
|
|
33
34
|
Index("ix_project_name", "name", unique=True),
|
|
34
35
|
Index("ix_project_permalink", "permalink", unique=True),
|
|
36
|
+
Index("ix_project_external_id", "external_id", unique=True),
|
|
35
37
|
Index("ix_project_path", "path"),
|
|
36
38
|
Index("ix_project_created_at", "created_at"),
|
|
37
39
|
Index("ix_project_updated_at", "updated_at"),
|
|
@@ -39,6 +41,10 @@ class Project(Base):
|
|
|
39
41
|
|
|
40
42
|
# Core identity
|
|
41
43
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
44
|
+
# External UUID for API references - stable identifier that won't change
|
|
45
|
+
external_id: Mapped[str] = mapped_column(
|
|
46
|
+
String, unique=True, default=lambda: str(uuid.uuid4())
|
|
47
|
+
)
|
|
42
48
|
name: Mapped[str] = mapped_column(String, unique=True)
|
|
43
49
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
44
50
|
|
|
@@ -71,7 +77,7 @@ class Project(Base):
|
|
|
71
77
|
entities = relationship("Entity", back_populates="project", cascade="all, delete-orphan")
|
|
72
78
|
|
|
73
79
|
def __repr__(self) -> str: # pragma: no cover
|
|
74
|
-
return f"Project(id={self.id}, name='{self.name}', permalink='{self.permalink}', path='{self.path}')"
|
|
80
|
+
return f"Project(id={self.id}, external_id='{self.external_id}', name='{self.name}', permalink='{self.permalink}', path='{self.path}')"
|
|
75
81
|
|
|
76
82
|
|
|
77
83
|
@event.listens_for(Project, "before_insert")
|
basic_memory/models/search.py
CHANGED
|
@@ -1,8 +1,64 @@
|
|
|
1
|
-
"""Search
|
|
1
|
+
"""Search DDL statements for SQLite and Postgres.
|
|
2
|
+
|
|
3
|
+
The search_index table is created via raw DDL, not ORM models, because:
|
|
4
|
+
- SQLite uses FTS5 virtual tables (cannot be represented as ORM)
|
|
5
|
+
- Postgres uses composite primary keys and generated tsvector columns
|
|
6
|
+
- Both backends use raw SQL for all search operations via SearchIndexRow dataclass
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
from sqlalchemy import DDL
|
|
4
10
|
|
|
5
|
-
|
|
11
|
+
|
|
12
|
+
# Define Postgres search_index table with composite primary key and tsvector
|
|
13
|
+
# This DDL matches the Alembic migration schema (314f1ea54dc4)
|
|
14
|
+
# Used by tests to create the table without running full migrations
|
|
15
|
+
# NOTE: Split into separate DDL statements because asyncpg doesn't support
|
|
16
|
+
# multiple statements in a single execute call.
|
|
17
|
+
CREATE_POSTGRES_SEARCH_INDEX_TABLE = DDL("""
|
|
18
|
+
CREATE TABLE IF NOT EXISTS search_index (
|
|
19
|
+
id INTEGER NOT NULL,
|
|
20
|
+
project_id INTEGER NOT NULL,
|
|
21
|
+
title TEXT,
|
|
22
|
+
content_stems TEXT,
|
|
23
|
+
content_snippet TEXT,
|
|
24
|
+
permalink VARCHAR,
|
|
25
|
+
file_path VARCHAR,
|
|
26
|
+
type VARCHAR,
|
|
27
|
+
from_id INTEGER,
|
|
28
|
+
to_id INTEGER,
|
|
29
|
+
relation_type VARCHAR,
|
|
30
|
+
entity_id INTEGER,
|
|
31
|
+
category VARCHAR,
|
|
32
|
+
metadata JSONB,
|
|
33
|
+
created_at TIMESTAMP WITH TIME ZONE,
|
|
34
|
+
updated_at TIMESTAMP WITH TIME ZONE,
|
|
35
|
+
textsearchable_index_col tsvector GENERATED ALWAYS AS (
|
|
36
|
+
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content_stems, ''))
|
|
37
|
+
) STORED,
|
|
38
|
+
PRIMARY KEY (id, type, project_id),
|
|
39
|
+
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
|
|
40
|
+
)
|
|
41
|
+
""")
|
|
42
|
+
|
|
43
|
+
CREATE_POSTGRES_SEARCH_INDEX_FTS = DDL("""
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_search_index_fts ON search_index USING gin(textsearchable_index_col)
|
|
45
|
+
""")
|
|
46
|
+
|
|
47
|
+
CREATE_POSTGRES_SEARCH_INDEX_METADATA = DDL("""
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_search_index_metadata_gin ON search_index USING gin(metadata jsonb_path_ops)
|
|
49
|
+
""")
|
|
50
|
+
|
|
51
|
+
# Partial unique index on (permalink, project_id) for non-null permalinks
|
|
52
|
+
# This prevents duplicate permalinks per project and is used by upsert operations
|
|
53
|
+
# in PostgresSearchRepository to handle race conditions during parallel indexing
|
|
54
|
+
CREATE_POSTGRES_SEARCH_INDEX_PERMALINK = DDL("""
|
|
55
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uix_search_index_permalink_project
|
|
56
|
+
ON search_index (permalink, project_id)
|
|
57
|
+
WHERE permalink IS NOT NULL
|
|
58
|
+
""")
|
|
59
|
+
|
|
60
|
+
# Define FTS5 virtual table creation for SQLite only
|
|
61
|
+
# This DDL is executed separately for SQLite databases
|
|
6
62
|
CREATE_SEARCH_INDEX = DDL("""
|
|
7
63
|
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|
8
64
|
-- Core entity fields
|