From f04b33cc72b0f576ddf669acf83c49aa4fc87fa9 Mon Sep 17 00:00:00 2001 From: phernandez Date: Tue, 28 Apr 2026 21:51:26 -0500 Subject: [PATCH] fix(core): skip Obsidian callouts in observation parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obsidian callout syntax `> [!info] Title` was being parsed as Basic Memory observations with categories like `!info`, `!warning`, `!quote`, polluting the knowledge graph for any user indexing an Obsidian vault. The observation rule iterated over every inline token regardless of parent context, so the bracket regex matched callout type identifiers the same as it would `[design]` or `[note]`. The relation rule already tracks list-item nesting; this applies the same pattern by tracking blockquote depth and skipping observation parsing inside one. This is broader than just suppressing leading-`!` brackets: blockquotes are quoted/aside content by definition, not authored knowledge graph observations. Anything inside `> ...` is now skipped — Obsidian callouts, normal citations, all of it. Closes #738 Signed-off-by: phernandez --- src/basic_memory/markdown/plugins.py | 15 ++++++ tests/markdown/test_markdown_plugins.py | 72 +++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/basic_memory/markdown/plugins.py b/src/basic_memory/markdown/plugins.py index a23bcebf8..c75eb54eb 100644 --- a/src/basic_memory/markdown/plugins.py +++ b/src/basic_memory/markdown/plugins.py @@ -180,6 +180,9 @@ def observation_plugin(md: MarkdownIt) -> None: def observation_rule(state: Any) -> None: """Process observations in token stream.""" tokens = state.tokens + # Track blockquote nesting so Obsidian callouts (`> [!info] Title`) + # don't get parsed as observations with category `!info`. + blockquote_depth = 0 for idx in range(len(tokens)): token = tokens[idx] @@ -187,6 +190,18 @@ def observation_rule(state: Any) -> None: # Initialize meta for all tokens token.meta = token.meta or {} + if token.type == "blockquote_open": + blockquote_depth += 1 + continue + if token.type == "blockquote_close": + blockquote_depth -= 1 + continue + + # Skip parsing inside blockquotes — that's Obsidian callout + # territory, not Basic Memory observation syntax. + if blockquote_depth > 0: + continue + # Parse observations in list items if token.type == "inline" and is_observation(token): obs = parse_observation(token) diff --git a/tests/markdown/test_markdown_plugins.py b/tests/markdown/test_markdown_plugins.py index bd4440c6f..0fd3593c0 100644 --- a/tests/markdown/test_markdown_plugins.py +++ b/tests/markdown/test_markdown_plugins.py @@ -159,6 +159,78 @@ def test_observation_excludes_html_color_codes(): assert is_observation(token), "Real hashtag with color code should still be observation" +def test_observation_skips_obsidian_callouts(): + """Test that Obsidian callout syntax is NOT parsed as observations (issue #738). + + Callouts use `> [!type] title` which previously matched the bracket-content + regex and produced spurious observations with categories like `!info`, + `!warning`, `!quote`. The blockquote prefix (`>`) is the distinguishing + feature — Basic Memory observations are list items, not blockquote lines. + """ + md = MarkdownIt().use(observation_plugin) + + callout_doc = dedent(""" + > [!info] Information title + > Body of the info callout. + + > [!warning] Heads up + > Body of the warning. + + > [!quote] Citation + > Quoted text here. + + - [design] Real observation outside the callout + """) + + tokens = md.parse(callout_doc) + + # No callout content should produce an observation + callout_observations = [ + t.meta.get("observation") + for t in tokens + if t.type == "inline" and t.meta and t.meta.get("observation") + if t.meta["observation"]["category"] + and t.meta["observation"]["category"].startswith("!") + ] + assert callout_observations == [], ( + f"Obsidian callouts should not produce observations, got: {callout_observations}" + ) + + # Real observation outside the blockquote must still parse + real_observations = [ + t.meta["observation"] + for t in tokens + if t.type == "inline" and t.meta and t.meta.get("observation") + ] + assert len(real_observations) == 1 + assert real_observations[0]["category"] == "design" + assert real_observations[0]["content"] == "Real observation outside the callout" + + +def test_observation_skipped_inside_any_blockquote(): + """Even non-callout `[bracket]` content in a blockquote should not be + treated as an observation — blockquote contents are quoted/aside text, + not knowledge graph observations. + """ + md = MarkdownIt().use(observation_plugin) + + quoted_doc = dedent(""" + > [design] This looks like an observation but lives in a blockquote. + + - [design] Real observation + """) + + tokens = md.parse(quoted_doc) + observations = [ + t.meta["observation"] + for t in tokens + if t.type == "inline" and t.meta and t.meta.get("observation") + ] + # Only the bullet observation should be picked up. + assert len(observations) == 1 + assert observations[0]["content"] == "Real observation" + + def test_relation_plugin(): """Test relation plugin.""" md = MarkdownIt().use(relation_plugin)