Skip to content

Commit 4ed4501

Browse files
authored
feat(core): index Markdown links to project files (#1514)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 44e6a99 commit 4ed4501

9 files changed

Lines changed: 266 additions & 2 deletions

File tree

docs/MARKDOWN_RELATIONS.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Markdown path links
2+
3+
Basic Memory indexes ordinary Markdown links to files in the same project as
4+
`links_to` relations, alongside existing wikilinks:
5+
6+
```markdown
7+
See [the guide](../guides/Getting%20Started.md#installation).
8+
```
9+
10+
The destination is resolved relative to the note containing the link. A leading
11+
`/` addresses the project root. Percent-encoded filenames are decoded; fragments
12+
and query strings do not change which file the relation targets. Reference-style
13+
Markdown links work too.
14+
15+
These are exact file paths. Basic Memory does not guess a title, add `.md`, apply
16+
filename aliases, or search another project when the target is missing. The graph
17+
stores a normalized project-root target such as `/guides/Getting Started.md`;
18+
missing targets remain unresolved and can resolve when indexed later.
19+
20+
External URLs, `mailto:` and `file:` links, fragment-only links, paths that escape
21+
the project, images, and links inside code do not create relations. Ordinary
22+
Markdown links always create `links_to` edges; typed relation syntax continues to
23+
use wikilinks. Notes with `bm_parse_semantics: false` remain graph-silent.
24+
25+
The authored Markdown is preserved. Existing notes gain these relations on their
26+
next edit or reindex. This supports interoperable Markdown navigation; it does not
27+
by itself declare full Open Knowledge Format conformance or add an export format.

src/basic_memory/markdown/entity_parser.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from markdown_it import MarkdownIt
1515

1616
from basic_memory.markdown.plugins import observation_plugin, relation_plugin
17+
from basic_memory.markdown.path_links import markdown_link_target
1718
from basic_memory.markdown.schemas import (
1819
EntityFrontmatter,
1920
EntityMarkdown,
@@ -154,7 +155,7 @@ class EntityContent:
154155
relations: list[Relation] = field(default_factory=list)
155156

156157

157-
def parse(content: str) -> EntityContent:
158+
def parse(content: str, *, source_path: str | None = None) -> EntityContent:
158159
"""Parse markdown content into EntityMarkdown."""
159160

160161
# Parse content for observations and relations using markdown-it
@@ -163,6 +164,17 @@ def parse(content: str) -> EntityContent:
163164

164165
if content:
165166
for token in md.parse(content):
167+
# MarkdownIt owns link syntax, including escapes, reference links and
168+
# code exclusion. Rooted targets retain exact project-path semantics
169+
# through deferred resolution without changing the authored body.
170+
if source_path is not None:
171+
for child in token.children or []:
172+
if child.type == "link_open":
173+
href = child.attrGet("href")
174+
assert isinstance(href, str)
175+
target = markdown_link_target(href, source_path) if href else None
176+
if target is not None:
177+
relations.append(Relation(type="links_to", target=target))
166178
# check for observations and relations
167179
if token.meta:
168180
if "observation" in token.meta:
@@ -349,7 +361,16 @@ async def parse_markdown_content(
349361
or (isinstance(semantic_setting, str) and semantic_setting.lower() == "false")
350362
)
351363
entity_content = (
352-
parse(post.content) if parse_semantics else EntityContent(content=post.content)
364+
parse(
365+
post.content,
366+
source_path=(
367+
file_path.relative_to(self.base_path).as_posix()
368+
if file_path.is_absolute()
369+
else file_path.as_posix()
370+
),
371+
)
372+
if parse_semantics
373+
else EntityContent(content=post.content)
353374
)
354375

355376
# The parser reports only a qualifier the author plainly meant: an unknown kind,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Project-local targets carried by ordinary Markdown links."""
2+
3+
from pathlib import PurePosixPath
4+
from urllib.parse import unquote, urlsplit
5+
6+
7+
def markdown_link_target(href: str, source_path: str) -> str | None:
8+
"""Return a project-root path, excluding URLs and paths escaping the project."""
9+
try:
10+
parsed = urlsplit(href)
11+
except ValueError:
12+
# A malformed URL remains authored prose, not a failed note write.
13+
return None
14+
if parsed.scheme or parsed.netloc or not parsed.path:
15+
return None
16+
path = unquote(parsed.path)
17+
if "\\" in path or "\x00" in path:
18+
return None
19+
parts = [] if path.startswith("/") else list(PurePosixPath(source_path).parent.parts)
20+
for part in path.split("/"):
21+
if part in {"", "."}:
22+
continue
23+
if part == "..":
24+
if not parts:
25+
return None
26+
parts.pop()
27+
else:
28+
parts.append(part)
29+
return "/" + "/".join(parts) if parts else None

src/basic_memory/services/bulk_link_resolver.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ class RelationTargetReference:
3434
@classmethod
3535
def parse(cls, link_text: str) -> "RelationTargetReference":
3636
"""Normalize wikilink syntax once for the whole bulk-resolution pass."""
37+
if link_text.startswith("/"):
38+
return cls(original=link_text, identifier=link_text, explicitly_qualified=False)
3739
clean_text, _ = normalize_link_text(link_text)
3840
return cls(
3941
original=link_text,
@@ -206,6 +208,11 @@ def resolve(self, target: RelationTargetReference) -> Entity | None:
206208
"""Resolve one parsed target without additional I/O."""
207209
current_index = self.entity_indexes[self.current_project_id]
208210

211+
# Rooted Markdown targets are file identities, never title/permalink or
212+
# cross-project guesses, including while their target is still absent.
213+
if target.identifier.startswith("/"):
214+
return current_index.by_file_path.get(target.identifier[1:])
215+
209216
try:
210217
external_id = str(uuid_mod.UUID(target.identifier))
211218
except ValueError:

src/basic_memory/services/link_resolver.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,14 @@ async def resolve_link(
183183
"""
184184
logger.trace(f"Resolving link: {link_text} (source: {source_path})")
185185

186+
# Markdown hrefs are normalized to project-root paths by the parser.
187+
# They must not fall through to aliases, titles or another project.
188+
if link_text.startswith("/"):
189+
async with db.scoped_session(self.session_maker, session) as active_session:
190+
return await self.entity_repository.get_by_file_path(
191+
active_session, link_text[1:], load_relations=load_relations
192+
)
193+
186194
# Clean link text and extract any alias
187195
clean_text, alias = self._normalize_link_text(link_text)
188196
explicit_project_reference = "::" in clean_text

src/basic_memory/services/note_preparation.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,10 @@ async def resolve_deferred_self_relation(
950950
entity: Entity,
951951
session: AsyncSession | None = None,
952952
) -> Entity | None:
953+
# Background resolution excludes self-edges, so exact Markdown paths must
954+
# resolve here before wikilink alias parsing can reinterpret filename bytes.
955+
if target.startswith("/"):
956+
return entity if target[1:] == entity.file_path else None
953957
clean_target = target.strip()
954958
if clean_target.startswith("[[") and clean_target.endswith("]]"):
955959
clean_target = clean_target[2:-2].strip()
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Markdown links reach the project graph without changing authored content."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
from fastmcp import Client
7+
8+
from basic_memory import db
9+
from basic_memory.repository.entity_repository import EntityRepository
10+
from basic_memory.repository.relation_repository import RelationRepository
11+
from basic_memory.services.bulk_link_resolver import BulkLinkResolver
12+
13+
14+
@pytest.mark.asyncio
15+
async def test_markdown_paths_are_indexed_and_resolved_exactly(
16+
mcp_server, app, app_config, test_project, engine_factory
17+
):
18+
body = "See [target](../targets/Guide.md#details) and [web](https://example.com).\n"
19+
async with Client(mcp_server) as client:
20+
for title, directory, content in [
21+
("Guide", "targets", "# Guide\n\n## Details\nContent."),
22+
("Source", "notes", body),
23+
]:
24+
result = await client.call_tool(
25+
"write_note",
26+
{
27+
"title": title,
28+
"directory": directory,
29+
"content": content,
30+
"project": test_project.name,
31+
},
32+
)
33+
assert not result.is_error
34+
35+
_, session_maker = engine_factory
36+
entities = EntityRepository(project_id=test_project.id)
37+
relations = RelationRepository(project_id=test_project.id)
38+
async with db.scoped_session(session_maker) as session:
39+
target = await entities.get_by_file_path(session, "targets/Guide.md")
40+
assert target is not None
41+
edges = await relations.find_by_type(session, "links_to")
42+
assert [edge.to_name for edge in edges] == ["/targets/Guide.md"]
43+
assert edges[0].to_id == target.id
44+
resolved = await BulkLinkResolver(entities, app_config).resolve_relation_targets(
45+
[edges[0].to_name, "/Guide", "/targets/guide.md"], session=session
46+
)
47+
resolved_target = resolved[edges[0].to_name]
48+
assert resolved_target is not None
49+
assert resolved_target.id == target.id
50+
assert resolved["/Guide"] is None
51+
assert resolved["/targets/guide.md"] is None
52+
53+
assert body.strip() in (Path(test_project.path) / "notes" / "Source.md").read_text()
54+
55+
56+
@pytest.mark.asyncio
57+
async def test_markdown_self_link_is_resolved_when_written(
58+
mcp_server, app, test_project, engine_factory
59+
):
60+
async with Client(mcp_server) as client:
61+
await client.call_tool(
62+
"write_note",
63+
{
64+
"title": "Self",
65+
"directory": "notes",
66+
"content": "[self](Self.md)",
67+
"project": test_project.name,
68+
},
69+
)
70+
_, session_maker = engine_factory
71+
async with db.scoped_session(session_maker) as session:
72+
source = await EntityRepository(project_id=test_project.id).get_by_file_path(
73+
session, "notes/Self.md"
74+
)
75+
assert source is not None
76+
edges = await RelationRepository(project_id=test_project.id).find_by_type(
77+
session, "links_to"
78+
)
79+
assert len(edges) == 1
80+
assert edges[0].to_name == "/notes/Self.md"
81+
assert edges[0].to_id == source.id

tests/markdown/test_path_links.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Ordinary Markdown links retain exact project-local path semantics."""
2+
3+
import pytest
4+
5+
from basic_memory.markdown.entity_parser import EntityParser, parse
6+
from basic_memory.markdown.path_links import markdown_link_target
7+
8+
9+
@pytest.mark.parametrize(
10+
("href", "expected"),
11+
[
12+
("../Guide%20One.md#section", "/Guide One.md"),
13+
("same.md", "/notes/same.md"),
14+
("./nested/../same.md", "/notes/same.md"),
15+
("/root.md", "/root.md"),
16+
("../../outside.md", None),
17+
("https://example.com/note.md", None),
18+
("//example.com/note.md", None),
19+
("mailto:me@example.com", None),
20+
("file:///tmp/note.md", None),
21+
("https://[broken", None),
22+
("#section", None),
23+
("../", None),
24+
("bad%00.md", None),
25+
("bad%5Cpath.md", None),
26+
],
27+
)
28+
def test_markdown_target_is_bounded_to_project(href, expected):
29+
assert markdown_link_target(href, "notes/source.md") == expected
30+
31+
32+
def test_markdown_parser_uses_real_links_without_rewriting_content():
33+
content = """See [guide](../Guide%20One.md#section) and [reference][ref].
34+
![image](picture.png) and `[code](code.md)` and [web](https://example.com).
35+
[[Existing Wiki]]
36+
37+
[ref]: next.md
38+
"""
39+
parsed = parse(content, source_path="notes/source.md")
40+
assert parsed.content == content
41+
assert [(relation.type, relation.target) for relation in parsed.relations] == [
42+
("links_to", "/Guide One.md"),
43+
("links_to", "/notes/next.md"),
44+
("links_to", "Existing Wiki"),
45+
]
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_remote_content_parsing_respects_semantic_opt_out(tmp_path):
50+
parser = EntityParser(tmp_path)
51+
body = "[not indexed](target.md)"
52+
content = "---\nbm_parse_semantics: false\n---\n" + body
53+
parsed = await parser.parse_markdown_content(tmp_path / "absent.md", content)
54+
assert parsed.content == body
55+
assert parsed.relations == []
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Rooted path relations cannot resolve to semantic aliases."""
2+
3+
from datetime import datetime, timezone
4+
5+
import pytest
6+
7+
from basic_memory import db
8+
from basic_memory.models import Entity
9+
10+
11+
@pytest.mark.asyncio
12+
async def test_rooted_path_resolves_only_exact_file(
13+
link_resolver, entity_repository, test_project, session_maker
14+
):
15+
now = datetime.now(timezone.utc)
16+
entity = Entity(
17+
title="Guide",
18+
note_type="note",
19+
content_type="text/markdown",
20+
file_path="notes/Guide.md",
21+
permalink="guide",
22+
created_at=now,
23+
updated_at=now,
24+
project_id=test_project.id,
25+
)
26+
async with db.scoped_session(session_maker) as session:
27+
await entity_repository.add(session, entity)
28+
resolved = await link_resolver.resolve_link("/notes/Guide.md")
29+
assert resolved is not None
30+
assert resolved.id == entity.id
31+
assert await link_resolver.resolve_link("/guide") is None
32+
assert await link_resolver.resolve_link("/notes/guide.md") is None

0 commit comments

Comments
 (0)