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.
- basic_memory/__init__.py +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +130 -20
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -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 +87 -20
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/__init__.py +4 -1
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +180 -23
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +9 -64
- basic_memory/api/routers/project_router.py +460 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +136 -11
- basic_memory/api/routers/search_router.py +5 -5
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- 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 +80 -10
- basic_memory/cli/auth.py +300 -0
- basic_memory/cli/commands/__init__.py +15 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +127 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +240 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +99 -0
- basic_memory/cli/commands/db.py +87 -12
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +47 -223
- basic_memory/cli/commands/import_claude_conversations.py +48 -171
- basic_memory/cli/commands/import_claude_projects.py +53 -160
- basic_memory/cli/commands/import_memory_json.py +55 -111
- basic_memory/cli/commands/mcp.py +67 -11
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +52 -34
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +14 -6
- basic_memory/config.py +580 -26
- basic_memory/db.py +285 -28
- 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 +16 -185
- basic_memory/file_utils.py +318 -54
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +100 -0
- basic_memory/importers/chatgpt_importer.py +245 -0
- basic_memory/importers/claude_conversations_importer.py +192 -0
- basic_memory/importers/claude_projects_importer.py +184 -0
- basic_memory/importers/memory_json_importer.py +128 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/entity_parser.py +182 -23
- basic_memory/markdown/markdown_processor.py +70 -7
- basic_memory/markdown/plugins.py +43 -23
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +38 -14
- basic_memory/mcp/async_client.py +135 -4
- 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 +155 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +61 -9
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +249 -0
- basic_memory/mcp/tools/edit_note.py +325 -0
- basic_memory/mcp/tools/list_directory.py +157 -0
- basic_memory/mcp/tools/move_note.py +549 -0
- basic_memory/mcp/tools/project_management.py +204 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +265 -0
- basic_memory/mcp/tools/recent_activity.py +528 -0
- basic_memory/mcp/tools/search.py +377 -24
- basic_memory/mcp/tools/utils.py +402 -16
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +82 -17
- basic_memory/models/project.py +93 -0
- basic_memory/models/search.py +68 -8
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +437 -8
- basic_memory/repository/observation_repository.py +36 -3
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +140 -0
- basic_memory/repository/relation_repository.py +79 -4
- basic_memory/repository/repository.py +148 -29
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +79 -268
- 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/__init__.py +22 -9
- basic_memory/schemas/base.py +131 -12
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +31 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +194 -25
- basic_memory/schemas/project_info.py +213 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/response.py +85 -28
- basic_memory/schemas/search.py +36 -35
- basic_memory/schemas/sync_report.py +72 -0
- 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/__init__.py +2 -1
- basic_memory/services/context_service.py +451 -138
- basic_memory/services/directory_service.py +310 -0
- basic_memory/services/entity_service.py +636 -71
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +402 -33
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +50 -56
- basic_memory/services/project_service.py +888 -0
- basic_memory/services/search_service.py +232 -37
- basic_memory/sync/__init__.py +4 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +1200 -109
- basic_memory/sync/watch_service.py +432 -135
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +407 -54
- basic_memory-0.17.4.dist-info/METADATA +617 -0
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -206
- basic_memory/cli/commands/tools.py +0 -157
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -170
- basic_memory/mcp/tools/notes.py +0 -202
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -31
- basic_memory-0.7.0.dist-info/METADATA +0 -378
- basic_memory-0.7.0.dist-info/RECORD +0 -82
- {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
|
|
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
|
|
20
|
-
3. Try exact
|
|
21
|
-
4.
|
|
22
|
-
5.
|
|
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(
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
45
|
-
if
|
|
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(
|
|
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 =
|
|
58
|
-
logger.
|
|
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
|
-
|
|
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]
|