basic-memory 0.7.0__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 (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,216 @@
1
+ """Shared initialization service for Basic Memory.
2
+
3
+ This module provides shared initialization functions used by both CLI and API
4
+ to ensure consistent application startup across all entry points.
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+
13
+ from loguru import logger
14
+
15
+ from basic_memory import db
16
+ from basic_memory.config import BasicMemoryConfig
17
+ from basic_memory.models import Project
18
+ from basic_memory.repository import (
19
+ ProjectRepository,
20
+ )
21
+
22
+
23
+ async def initialize_database(app_config: BasicMemoryConfig) -> None:
24
+ """Initialize database with migrations handled automatically by get_or_create_db.
25
+
26
+ Args:
27
+ app_config: The Basic Memory project configuration
28
+
29
+ Note:
30
+ Database migrations are now handled automatically when the database
31
+ connection is first established via get_or_create_db().
32
+ """
33
+ try:
34
+ await db.get_or_create_db(app_config.database_path)
35
+ logger.info("Database initialization completed")
36
+ except Exception as e:
37
+ logger.error(f"Error during database initialization: {e}")
38
+ raise
39
+
40
+
41
+ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
42
+ """Ensure all projects in config.json exist in the projects table and vice versa.
43
+
44
+ This uses the ProjectService's synchronize_projects method to ensure bidirectional
45
+ synchronization between the configuration file and the database.
46
+
47
+ Args:
48
+ app_config: The Basic Memory application configuration
49
+ """
50
+ logger.info("Reconciling projects from config with database...")
51
+
52
+ # Get database session (engine already created by initialize_database)
53
+ _, session_maker = await db.get_or_create_db(
54
+ db_path=app_config.database_path,
55
+ db_type=db.DatabaseType.FILESYSTEM,
56
+ )
57
+ project_repository = ProjectRepository(session_maker)
58
+
59
+ # Import ProjectService here to avoid circular imports
60
+ from basic_memory.services.project_service import ProjectService
61
+
62
+ # Create project service and synchronize projects
63
+ project_service = ProjectService(repository=project_repository)
64
+ try:
65
+ await project_service.synchronize_projects()
66
+ logger.info("Projects successfully reconciled between config and database")
67
+ except Exception as e:
68
+ logger.error(f"Error during project synchronization: {e}")
69
+ logger.info("Continuing with initialization despite synchronization error")
70
+
71
+
72
+ async def initialize_file_sync(
73
+ app_config: BasicMemoryConfig,
74
+ ) -> None:
75
+ """Initialize file synchronization services. This function starts the watch service and does not return
76
+
77
+ Args:
78
+ app_config: The Basic Memory project configuration
79
+
80
+ Returns:
81
+ The watch service task that's monitoring file changes
82
+ """
83
+ # Never start file watching during tests. Even "background" watchers add tasks/threads
84
+ # and can interact badly with strict asyncio teardown (especially on Windows/aiosqlite).
85
+ # Skip file sync in test environments to avoid interference with tests
86
+ if app_config.is_test_env:
87
+ logger.info("Test environment detected - skipping file sync initialization")
88
+ return None
89
+
90
+ # delay import
91
+ from basic_memory.sync import WatchService
92
+
93
+ # Get database session (migrations already run if needed)
94
+ _, session_maker = await db.get_or_create_db(
95
+ db_path=app_config.database_path,
96
+ db_type=db.DatabaseType.FILESYSTEM,
97
+ )
98
+ project_repository = ProjectRepository(session_maker)
99
+
100
+ # Initialize watch service
101
+ watch_service = WatchService(
102
+ app_config=app_config,
103
+ project_repository=project_repository,
104
+ quiet=True,
105
+ )
106
+
107
+ # Get active projects
108
+ active_projects = await project_repository.get_active_projects()
109
+
110
+ # Filter to constrained project if MCP server was started with --project
111
+ constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
112
+ if constrained_project:
113
+ active_projects = [p for p in active_projects if p.name == constrained_project]
114
+ logger.info(f"Background sync constrained to project: {constrained_project}")
115
+
116
+ # Start sync for all projects as background tasks (non-blocking)
117
+ async def sync_project_background(project: Project):
118
+ """Sync a single project in the background."""
119
+ # avoid circular imports
120
+ from basic_memory.sync.sync_service import get_sync_service
121
+
122
+ logger.info(f"Starting background sync for project: {project.name}")
123
+ try:
124
+ # Create sync service
125
+ sync_service = await get_sync_service(project)
126
+
127
+ sync_dir = Path(project.path)
128
+ await sync_service.sync(sync_dir, project_name=project.name)
129
+ logger.info(f"Background sync completed successfully for project: {project.name}")
130
+ except Exception as e: # pragma: no cover
131
+ logger.error(f"Error in background sync for project {project.name}: {e}")
132
+
133
+ # Create background tasks for all project syncs (non-blocking)
134
+ sync_tasks = [
135
+ asyncio.create_task(sync_project_background(project)) for project in active_projects
136
+ ]
137
+ logger.info(f"Created {len(sync_tasks)} background sync tasks")
138
+
139
+ # Don't await the tasks - let them run in background while we continue
140
+
141
+ # Then start the watch service in the background
142
+ logger.info("Starting watch service for all projects")
143
+
144
+ # run the watch service
145
+ await watch_service.run()
146
+ logger.info("Watch service started")
147
+
148
+ return None
149
+
150
+
151
+ async def initialize_app(
152
+ app_config: BasicMemoryConfig,
153
+ ):
154
+ """Initialize the Basic Memory application.
155
+
156
+ This function handles all initialization steps:
157
+ - Running database migrations
158
+ - Reconciling projects from config.json with projects table
159
+ - Setting up file synchronization
160
+ - Starting background migration for legacy project data
161
+
162
+ Args:
163
+ app_config: The Basic Memory project configuration
164
+ """
165
+ # Skip initialization in cloud mode - cloud manages its own projects
166
+ if app_config.cloud_mode_enabled:
167
+ logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
168
+ return
169
+
170
+ logger.info("Initializing app...")
171
+ # Initialize database first
172
+ await initialize_database(app_config)
173
+
174
+ # Reconcile projects from config.json with projects table
175
+ await reconcile_projects_with_config(app_config)
176
+
177
+ logger.info("App initialization completed (migration running in background if needed)")
178
+
179
+
180
+ def ensure_initialization(app_config: BasicMemoryConfig) -> None:
181
+ """Ensure initialization runs in a synchronous context.
182
+
183
+ This is a wrapper for the async initialize_app function that can be
184
+ called from synchronous code like CLI entry points.
185
+
186
+ No-op if app_config.cloud_mode == True. Cloud basic memory manages it's own projects
187
+
188
+ Args:
189
+ app_config: The Basic Memory project configuration
190
+ """
191
+ # Skip initialization in cloud mode - cloud manages its own projects
192
+ if app_config.cloud_mode_enabled:
193
+ logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
194
+ return
195
+
196
+ async def _init_and_cleanup():
197
+ """Initialize app and clean up database connections.
198
+
199
+ Database connections created during initialization must be cleaned up
200
+ before the event loop closes, otherwise the process will hang indefinitely.
201
+ """
202
+ try:
203
+ await initialize_app(app_config)
204
+ finally:
205
+ # Always cleanup database connections to prevent process hang
206
+ await db.shutdown_db()
207
+
208
+ # On Windows, use SelectorEventLoop to avoid ProactorEventLoop cleanup issues
209
+ # The ProactorEventLoop can raise "IndexError: pop from an empty deque" during
210
+ # event loop cleanup when there are pending handles. SelectorEventLoop is more
211
+ # stable for our use case (no subprocess pipes or named pipes needed).
212
+ if sys.platform == "win32":
213
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
214
+
215
+ asyncio.run(_init_and_cleanup())
216
+ logger.info("Initialization completed successfully")
@@ -1,14 +1,14 @@
1
1
  """Service for resolving markdown links to permalinks."""
2
2
 
3
- from typing import Optional, Tuple, List
3
+ from typing import Optional, Tuple
4
+
4
5
 
5
6
  from loguru import logger
6
7
 
7
- from basic_memory.repository.entity_repository import EntityRepository
8
- from basic_memory.repository.search_repository import SearchIndexRow
9
- from basic_memory.services.search_service import SearchService
10
8
  from basic_memory.models import Entity
9
+ from basic_memory.repository.entity_repository import EntityRepository
11
10
  from basic_memory.schemas.search import SearchQuery, SearchItemType
11
+ from basic_memory.services.search_service import SearchService
12
12
 
13
13
 
14
14
  class LinkResolver:
@@ -16,10 +16,10 @@ class LinkResolver:
16
16
 
17
17
  Uses a combination of exact matching and search-based resolution:
18
18
  1. Try exact permalink match (fastest)
19
- 2. Try permalink pattern match (for wildcards)
20
- 3. Try exact title match
21
- 4. Fall back to search for fuzzy matching
22
- 5. Generate new permalink if no match found
19
+ 2. Try exact title match
20
+ 3. Try exact file path match
21
+ 4. Try file path with .md extension (for folder/title patterns)
22
+ 5. Fall back to search for fuzzy matching
23
23
  """
24
24
 
25
25
  def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
@@ -27,9 +27,17 @@ class LinkResolver:
27
27
  self.entity_repository = entity_repository
28
28
  self.search_service = search_service
29
29
 
30
- async def resolve_link(self, link_text: str, use_search: bool = True) -> Optional[Entity]:
31
- """Resolve a markdown link to a permalink."""
32
- logger.debug(f"Resolving link: {link_text}")
30
+ async def resolve_link(
31
+ self, link_text: str, use_search: bool = True, strict: bool = False
32
+ ) -> Optional[Entity]:
33
+ """Resolve a markdown link to a permalink.
34
+
35
+ Args:
36
+ link_text: The link text to resolve
37
+ use_search: Whether to use search-based fuzzy matching as fallback
38
+ strict: If True, only exact matches are allowed (no fuzzy search fallback)
39
+ """
40
+ logger.trace(f"Resolving link: {link_text}")
33
41
 
34
42
  # Clean link text and extract any alias
35
43
  clean_text, alias = self._normalize_link_text(link_text)
@@ -41,24 +49,45 @@ class LinkResolver:
41
49
  return entity
42
50
 
43
51
  # 2. Try exact title match
44
- entity = await self.entity_repository.get_by_title(clean_text)
45
- if entity:
52
+ found = await self.entity_repository.get_by_title(clean_text)
53
+ if found:
54
+ # Return first match if there are duplicates (consistent behavior)
55
+ entity = found[0]
46
56
  logger.debug(f"Found title match: {entity.title}")
47
57
  return entity
48
58
 
59
+ # 3. Try file path
60
+ found_path = await self.entity_repository.get_by_file_path(clean_text)
61
+ if found_path:
62
+ logger.debug(f"Found entity with path: {found_path.file_path}")
63
+ return found_path
64
+
65
+ # 4. Try file path with .md extension if not already present
66
+ if not clean_text.endswith(".md") and "/" in clean_text:
67
+ file_path_with_md = f"{clean_text}.md"
68
+ found_path_md = await self.entity_repository.get_by_file_path(file_path_with_md)
69
+ if found_path_md:
70
+ logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
71
+ return found_path_md
72
+
73
+ # In strict mode, don't try fuzzy search - return None if no exact match found
74
+ if strict:
75
+ return None
76
+
77
+ # 5. Fall back to search for fuzzy matching (only if not in strict mode)
49
78
  if use_search and "*" not in clean_text:
50
- # 3. Fall back to search for fuzzy matching on title
51
79
  results = await self.search_service.search(
52
- query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]),
80
+ query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
53
81
  )
54
82
 
55
83
  if results:
56
84
  # Look for best match
57
- best_match = self._select_best_match(clean_text, results)
58
- logger.debug(
85
+ best_match = min(results, key=lambda x: x.score) # pyright: ignore
86
+ logger.trace(
59
87
  f"Selected best match from {len(results)} results: {best_match.permalink}"
60
88
  )
61
- return await self.entity_repository.get_by_permalink(best_match.permalink)
89
+ if best_match.permalink:
90
+ return await self.entity_repository.get_by_permalink(best_match.permalink)
62
91
 
63
92
  # if we couldn't find anything then return None
64
93
  return None
@@ -85,43 +114,8 @@ class LinkResolver:
85
114
  text, alias = text.split("|", 1)
86
115
  text = text.strip()
87
116
  alias = alias.strip()
117
+ else:
118
+ # Strip whitespace from text even if no alias
119
+ text = text.strip()
88
120
 
89
121
  return text, alias
90
-
91
- def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> SearchIndexRow:
92
- """Select best match from search results.
93
-
94
- Uses multiple criteria:
95
- 1. Word matches in title field
96
- 2. Word matches in path
97
- 3. Overall search score
98
- """
99
- # Get search terms for matching
100
- terms = search_text.lower().split()
101
-
102
- # Score each result
103
- scored_results = []
104
- for result in results:
105
- # Start with base score (lower is better)
106
- score = result.score
107
- assert score is not None
108
-
109
- # Parse path components
110
- path_parts = result.permalink.lower().split("/")
111
- last_part = path_parts[-1] if path_parts else ""
112
-
113
- # Title word match boosts
114
- term_matches = [term for term in terms if term in last_part]
115
- if term_matches:
116
- score *= 0.5 # Boost for each matching term
117
-
118
- # Exact title match is best
119
- if last_part == search_text.lower():
120
- score *= 0.2
121
-
122
- scored_results.append((score, result))
123
-
124
- # Sort by score (lowest first) and return best
125
- scored_results.sort(key=lambda x: x[0], reverse=True)
126
-
127
- return scored_results[0][1]