Skip to content
11 changes: 11 additions & 0 deletions docs/NOTE-FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,17 @@ Explicit relations:
- 'in response to' [[Incident Review]]
```

When the text before `[[` is ordinary prose rather than a relation type, add
`#bm:links_to` to force the reference to use the implicit `links_to` relation:

```markdown
- Mother [[Alice]] #bm:links_to
```

The directive is parser metadata and is not included in the observation or
relation context. It is especially useful when a single-token prose prefix
would otherwise be interpreted as an explicit relation type.

Bare wiki links and prose list items create implicit `links_to` relations:

```markdown
Expand Down
19 changes: 16 additions & 3 deletions src/basic_memory/markdown/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
# those bracket prefixes stay ordinary content (issue #1219).
_TIMESTAMP_VALUE = r"\d{1,3}:\d{2}(?::\d{2})?(?:[.,]\d{1,3})?"
_TIMESTAMP_CATEGORY = re.compile(rf"^{_TIMESTAMP_VALUE}(?:\s+-\s+{_TIMESTAMP_VALUE})?$")
_LINKS_TO_DIRECTIVE = re.compile(r"\s+#bm:links_to\s*$")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new relation directive

This introduces the only way to disambiguate a single-token prose prefix such as Mother [[Alice]], but neither the canonical docs/NOTE-FORMAT.md relation reference nor the write_note tool description mentions #bm:links_to. Those user-facing surfaces still state that any single token before [[ becomes a relation type, so users and tool-calling models cannot discover the new escape hatch and will continue producing unintended typed relations.

Useful? React with 👍 / 👎.



def remove_links_to_directive(content: str) -> tuple[str, bool]:
"""Remove an exact terminal ``#bm:links_to`` directive from content."""
match = _LINKS_TO_DIRECTIVE.search(content)
if not match:
return content, False
return content[: match.start()].rstrip(), True


def _is_task_marker_category(category: str) -> bool:
Expand Down Expand Up @@ -47,6 +56,7 @@ def is_observation(token: Token) -> bool:
return False
# Use token.tag which contains the actual content for test tokens, fallback to content
content = (token.tag or token.content).strip()
content, _ = remove_links_to_directive(content)
if not content: # pragma: no cover
return False
# if it's a markdown_task, return false
Expand Down Expand Up @@ -74,6 +84,7 @@ def parse_observation(token: Token) -> Dict[str, Any]:

# Use token.tag which contains the actual content for test tokens, fallback to content
content = (token.tag or token.content).strip()
content, _ = remove_links_to_directive(content)

# Parse [category] with regex; a timestamp-shaped prefix is not a category, so a
# hashtag-promoted transcript line keeps its timecode inside the content instead.
Expand Down Expand Up @@ -325,17 +336,19 @@ def relation_rule(state: Any) -> None:

# Only process inline tokens
if token.type == "inline":
content = token.tag or token.content
content_without_directive, has_directive = remove_links_to_directive(content)

# Check for explicit relations in list items
if in_list_item and is_explicit_relation(token):
if in_list_item and not has_directive and is_explicit_relation(token):
rel = parse_relation(token)
if rel:
token.meta["relations"] = [rel]

# Always check for inline links in any text
else:
content = token.tag or token.content
if "[[" in content:
rels = parse_inline_relations(content)
rels = parse_inline_relations(content_without_directive)
if rels:
token.meta["relations"] = token.meta.get("relations", []) + rels

Expand Down
3 changes: 3 additions & 0 deletions src/basic_memory/mcp/tools/write_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,15 @@ async def write_note(
- Explicit: `- relation_type [[Entity]] (optional context)`
- Quoted: `- "multi word relation type" [[Entity]] (optional context)`
- Quoted: `- 'multi word relation type' [[Entity]] (optional context)`
- Disambiguation: Add `#bm:links_to` when prose before `[[Entity]]`
must not be treated as a single-token relation type
- Inline: Any other `[[Entity]]` reference creates a `links_to` relation

Examples:
`- depends_on [[Content Parser]] (Need for semantic extraction)`
`- "based on" [[Design Notes]]`
`- 'in response to' [[Incident Review]]`
`- Mother [[Alice]] #bm:links_to`
`- implements [[Search Spec]] (Initial implementation)`
`- This feature extends [[Base Design]] and uses [[Core Utils]]`

Expand Down
63 changes: 62 additions & 1 deletion tests/markdown/test_relation_edge_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

from markdown_it import MarkdownIt

from basic_memory.markdown.plugins import relation_plugin, parse_relation, parse_inline_relations
from basic_memory.markdown.plugins import (
observation_plugin,
relation_plugin,
parse_relation,
parse_inline_relations,
)
from basic_memory.markdown.entity_parser import parse
from basic_memory.markdown.schemas import Relation


Expand Down Expand Up @@ -247,3 +253,58 @@ def test_bare_list_wikilink_is_inline_link_not_default_explicit_relation():

assert token.meta["relations"] == [{"type": "links_to", "target": "Target", "context": None}]
assert parse_relation(token) is None


def test_links_to_directive_forces_implicit_relations():
"""The terminal directive disambiguates a single-token relation prefix."""
md = MarkdownIt().use(relation_plugin)

tokens = md.parse("- Mother [[Alice]] #bm:links_to")
token = next(t for t in tokens if t.type == "inline")
assert token.meta["relations"] == [
{"type": "links_to", "target": "Alice", "context": None}
]

tokens = md.parse("- Mentions [[Alice]] and [[Bob]] #bm:links_to")
token = next(t for t in tokens if t.type == "inline")
assert token.meta["relations"] == [
{"type": "links_to", "target": "Alice", "context": None},
{"type": "links_to", "target": "Bob", "context": None},
]


def test_links_to_directive_is_terminal_and_explicit_relations_are_unchanged():
"""Only the exact terminal directive changes relation interpretation."""
md = MarkdownIt().use(relation_plugin)

tokens = md.parse("- spouse_of [[Alice]]")
token = next(t for t in tokens if t.type == "inline")
assert token.meta["relations"][0]["type"] == "spouse_of"

tokens = md.parse("- Mother [[Alice]] #bm:links_to later")
token = next(t for t in tokens if t.type == "inline")
assert token.meta["relations"][0]["type"] == "links_to"


def test_links_to_directive_is_not_an_observation_tag():
"""The directive is syntax, not a note tag or indexed observation text."""
md = MarkdownIt().use(observation_plugin).use(relation_plugin)

tokens = md.parse("- Mother [[Alice]] #bm:links_to")
token = next(t for t in tokens if t.type == "inline")
assert "observation" not in token.meta


def test_links_to_directive_preserves_source_and_observation_content():
"""The directive stays in source while remaining outside indexed semantics."""
source = "- [note] Mother [[Alice]] #bm:links_to\n"
parsed = parse(source)

assert parsed.content == source
assert len(parsed.observations) == 1
assert parsed.observations[0].category == "note"
assert parsed.observations[0].content == "Mother [[Alice]]"
assert parsed.observations[0].tags is None
assert len(parsed.relations) == 1
assert parsed.relations[0].type == "links_to"
assert parsed.relations[0].target == "Alice"
Loading