@@ -28,20 +28,81 @@ def __init__(self, entity_repository: EntityRepository, search_service: SearchSe
2828 self .search_service = search_service
2929
3030 async def resolve_link (
31- self , link_text : str , use_search : bool = True , strict : bool = False
31+ self ,
32+ link_text : str ,
33+ use_search : bool = True ,
34+ strict : bool = False ,
35+ source_path : Optional [str ] = None ,
3236 ) -> Optional [Entity ]:
3337 """Resolve a markdown link to a permalink.
3438
3539 Args:
3640 link_text: The link text to resolve
3741 use_search: Whether to use search-based fuzzy matching as fallback
3842 strict: If True, only exact matches are allowed (no fuzzy search fallback)
43+ source_path: Optional path of the source file containing the link.
44+ Used to prefer notes closer to the source (context-aware resolution).
3945 """
40- logger .trace (f"Resolving link: { link_text } " )
46+ logger .trace (f"Resolving link: { link_text } (source: { source_path } ) " )
4147
4248 # Clean link text and extract any alias
4349 clean_text , alias = self ._normalize_link_text (link_text )
4450
51+ # --- Path Resolution ---
52+ # Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)
53+ # for cross-platform compatibility. See entity_repository.py which normalizes
54+ # paths using Path().as_posix(). This allows consistent path operations here.
55+
56+ # --- Relative Path Resolution ---
57+ # Trigger: source_path is provided AND link contains "/"
58+ # Why: Resolve paths like [[nested/deep-note]] relative to source folder first
59+ # Outcome: [[nested/deep-note]] from testing/link-test.md → testing/nested/deep-note.md
60+ if source_path and "/" in clean_text :
61+ source_folder = source_path .rsplit ("/" , 1 )[0 ] if "/" in source_path else ""
62+ if source_folder :
63+ # Construct relative path from source folder
64+ relative_path = f"{ source_folder } /{ clean_text } "
65+
66+ # Try with .md extension
67+ if not relative_path .endswith (".md" ):
68+ relative_path_md = f"{ relative_path } .md"
69+ entity = await self .entity_repository .get_by_file_path (relative_path_md )
70+ if entity :
71+ return entity
72+
73+ # Try as-is (already has extension or is a permalink)
74+ entity = await self .entity_repository .get_by_file_path (relative_path )
75+ if entity :
76+ return entity
77+
78+ # When source_path is provided, use context-aware resolution:
79+ # Check both permalink and title matches, prefer closest to source.
80+ # Example: [[testing]] from folder/note.md prefers folder/testing.md
81+ # over a root testing.md with permalink "testing".
82+ if source_path :
83+ # Gather all potential matches
84+ candidates : list [Entity ] = []
85+
86+ # Check permalink match
87+ permalink_entity = await self .entity_repository .get_by_permalink (clean_text )
88+ if permalink_entity :
89+ candidates .append (permalink_entity )
90+
91+ # Check title matches
92+ title_entities = await self .entity_repository .get_by_title (clean_text )
93+ for entity in title_entities :
94+ # Avoid duplicates (permalink match might also be in title matches)
95+ if entity .id not in [c .id for c in candidates ]:
96+ candidates .append (entity )
97+
98+ if candidates :
99+ if len (candidates ) == 1 :
100+ return candidates [0 ]
101+ else :
102+ # Multiple candidates - pick closest to source
103+ return self ._find_closest_entity (candidates , source_path )
104+
105+ # Standard resolution (no source context): permalink first, then title
45106 # 1. Try exact permalink match first (most efficient)
46107 entity = await self .entity_repository .get_by_permalink (clean_text )
47108 if entity :
@@ -51,7 +112,7 @@ async def resolve_link(
51112 # 2. Try exact title match
52113 found = await self .entity_repository .get_by_title (clean_text )
53114 if found :
54- # Return first match if there are duplicates (consistent behavior)
115+ # Return first match (shortest path) if no source context
55116 entity = found [0 ]
56117 logger .debug (f"Found title match: { entity .title } " )
57118 return entity
@@ -108,7 +169,7 @@ def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
108169 if text .startswith ("[[" ) and text .endswith ("]]" ):
109170 text = text [2 :- 2 ]
110171
111- # Handle Obsidian-style aliases (format: [[actual|alias]])
172+ # Handle wiki link aliases (format: [[actual|alias]])
112173 alias = None
113174 if "|" in text :
114175 text , alias = text .split ("|" , 1 )
@@ -119,3 +180,72 @@ def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
119180 text = text .strip ()
120181
121182 return text , alias
183+
184+ def _find_closest_entity (self , entities : list [Entity ], source_path : str ) -> Entity :
185+ """Find the entity closest to the source file path.
186+
187+ Context-aware resolution: prefer notes in the same folder or closer in hierarchy.
188+
189+ Proximity Scoring Algorithm:
190+ - Priority 0: Same folder as source (best match)
191+ - Priority 1-N: Ancestor folders (N = levels up from source)
192+ - Priority 100+N: Descendant folders (N = levels down, deprioritized)
193+ - Priority 1000: Completely unrelated paths (least preferred)
194+ - Ties are broken by shortest absolute path (consistent behavior)
195+
196+ Args:
197+ entities: List of entities with the same title
198+ source_path: Path of the file containing the link
199+
200+ Returns:
201+ The entity closest to the source path
202+ """
203+ # Extract source folder (everything before the last /)
204+ source_folder = source_path .rsplit ("/" , 1 )[0 ] if "/" in source_path else ""
205+
206+ def path_proximity (entity : Entity ) -> Tuple [int , int ]:
207+ """Return (proximity_score, path_length) for sorting.
208+
209+ Lower is better for both values.
210+ """
211+ entity_path = entity .file_path
212+ entity_folder = entity_path .rsplit ("/" , 1 )[0 ] if "/" in entity_path else ""
213+
214+ # Trigger: entity is in the same folder as source
215+ # Why: same-folder notes are most contextually relevant
216+ # Outcome: priority = 0 (best), ties broken by shortest path
217+ if entity_folder == source_folder :
218+ return (0 , len (entity_path ))
219+
220+ # Trigger: entity is in an ancestor folder of source
221+ # e.g., source is "a/b/c/file.md", entity is "a/b/note.md" -> ancestor
222+ # Why: ancestors are contextually relevant (shared parent context)
223+ # Outcome: priority = levels_up (1, 2, 3...), closer ancestors preferred
224+ if source_folder .startswith (entity_folder + "/" ) if entity_folder else source_folder :
225+ # Count how many levels up
226+ if entity_folder :
227+ levels_up = source_folder .count ("/" ) - entity_folder .count ("/" )
228+ else :
229+ # Root level
230+ levels_up = source_folder .count ("/" ) + 1
231+ return (levels_up , len (entity_path ))
232+
233+ # Trigger: entity is in a descendant folder of source
234+ # e.g., source is "a/file.md", entity is "a/b/c/note.md" -> descendant
235+ # Why: descendants are less contextually relevant than ancestors
236+ # Outcome: priority = 100 + levels_down, significantly deprioritized
237+ if entity_folder .startswith (source_folder + "/" ) if source_folder else entity_folder :
238+ if source_folder :
239+ levels_down = entity_folder .count ("/" ) - source_folder .count ("/" )
240+ else :
241+ # Source is at root
242+ levels_down = entity_folder .count ("/" ) + 1
243+ return (100 + levels_down , len (entity_path ))
244+
245+ # Trigger: entity is in a completely unrelated path
246+ # Why: no folder relationship means minimal contextual relevance
247+ # Outcome: priority = 1000, only selected if no related paths exist
248+ return (1000 , len (entity_path ))
249+
250+ # Sort by proximity (lower is better), then by path length (shorter is better)
251+ return min (entities , key = path_proximity )
0 commit comments