diff --git a/docs/NOTE-FORMAT.md b/docs/NOTE-FORMAT.md index be1ebb718..2d666f08a 100644 --- a/docs/NOTE-FORMAT.md +++ b/docs/NOTE-FORMAT.md @@ -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 diff --git a/src/basic_memory/markdown/plugins.py b/src/basic_memory/markdown/plugins.py index 5dfd3536d..1a41ccc63 100644 --- a/src/basic_memory/markdown/plugins.py +++ b/src/basic_memory/markdown/plugins.py @@ -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*$") + + +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: @@ -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 @@ -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. @@ -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 diff --git a/src/basic_memory/mcp/tools/write_note.py b/src/basic_memory/mcp/tools/write_note.py index edd823635..7b521ccc8 100644 --- a/src/basic_memory/mcp/tools/write_note.py +++ b/src/basic_memory/mcp/tools/write_note.py @@ -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]]` diff --git a/tests/markdown/test_relation_edge_cases.py b/tests/markdown/test_relation_edge_cases.py index e38112bcc..f3a32ee59 100644 --- a/tests/markdown/test_relation_edge_cases.py +++ b/tests/markdown/test_relation_edge_cases.py @@ -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 @@ -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"