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.

Files changed (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {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.mcp.tools.utils import call_get
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
- project_url = active_project.project_url
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
- path = f"{project_url}/resource/{entity_path}"
105
- logger.info(f"Attempting to read note from Project: {active_project.name} URL: {path}")
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 direct lookup first
109
- response = await call_get(client, path, params={"page": page, "page_size": page_size})
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 '{path}': {e}")
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
- # Try to fetch the content using the found permalink
131
- path = f"{project_url}/resource/{result.permalink}"
132
- response = await call_get(
133
- client, path, params={"page": page, "page_size": page_size}
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
- resolved_project = await resolve_project_parameter(project)
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 most_active_project and most_active_count > 0:
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"{project_url}/memory/recent",
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"{project_url}/memory/recent",
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
- try:
293
- if last_activity is None or current_time > last_activity:
294
- last_activity = current_time
295
- except TypeError:
296
- # Handle timezone comparison issues by skipping this comparison
297
- if last_activity is None:
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
@@ -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.mcp.tools.utils import call_post
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
- response = await call_post(
364
- client,
365
- f"{project_url}/search/",
366
- json=search_query.model_dump(),
367
- params={"page": page, "page_size": page_size},
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:
@@ -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.mcp.tools.utils import call_put
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
- # Create or update via knowledge API
156
- logger.debug(f"Creating entity via API permalink={entity.permalink}")
157
- url = f"{project_url}/knowledge/entities/{entity.permalink}"
158
- response = await call_put(client, url, json=entity.model_dump())
159
- result = EntityResponse.model_validate(response.json())
160
-
161
- # Format semantic summary based on status code
162
- action = "Created" if response.status_code == 201 else "Updated"
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} status_code={response.status_code}"
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
- result = "\n".join(summary)
207
- return add_project_metadata(result, active_project.name)
229
+ summary_result = "\n".join(summary)
230
+ return add_project_metadata(summary_result, active_project.name)
@@ -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}/{self.content}"
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
@@ -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")
@@ -1,8 +1,64 @@
1
- """Search models and tables."""
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
- # Define FTS5 virtual table creation
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