diff --git a/src/basic_memory/markdown/plugins.py b/src/basic_memory/markdown/plugins.py
index d491dbbb..1926c27a 100644
--- a/src/basic_memory/markdown/plugins.py
+++ b/src/basic_memory/markdown/plugins.py
@@ -30,7 +30,9 @@ def is_observation(token: Token) -> bool:
# Check for proper observation format: [category] content
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
- has_tags = "#" in content
+ # Check for standalone hashtags (words starting with #)
+ # This excludes # in HTML attributes like color="#4285F4"
+ has_tags = any(part.startswith('#') for part in content.split())
return bool(match) or has_tags
diff --git a/src/basic_memory/models/knowledge.py b/src/basic_memory/models/knowledge.py
index 1b7faf0a..a7b6c778 100644
--- a/src/basic_memory/models/knowledge.py
+++ b/src/basic_memory/models/knowledge.py
@@ -162,9 +162,14 @@ class Observation(Base):
We can construct these because observations are always defined in
and owned by a single entity.
+
+ Content is truncated to 200 chars to stay under PostgreSQL's
+ btree index limit of 2704 bytes.
"""
+ # Truncate content to avoid exceeding PostgreSQL's btree index limit
+ content_for_permalink = self.content[:200] if len(self.content) > 200 else self.content
return generate_permalink(
- f"{self.entity.permalink}/observations/{self.category}/{self.content}"
+ f"{self.entity.permalink}/observations/{self.category}/{content_for_permalink}"
)
def __repr__(self) -> str: # pragma: no cover
diff --git a/tests/markdown/test_markdown_plugins.py b/tests/markdown/test_markdown_plugins.py
index 77223b29..ae069f6b 100644
--- a/tests/markdown/test_markdown_plugins.py
+++ b/tests/markdown/test_markdown_plugins.py
@@ -121,6 +121,44 @@ def test_observation_excludes_markdown_and_wiki_links():
assert not is_observation(token), "No space after category should not be valid observation"
+def test_observation_excludes_html_color_codes():
+ """Test that HTML color codes are NOT interpreted as hashtags.
+
+ This test validates the fix for issue #446 where:
+ - HTML color codes like #4285F4 in attributes were incorrectly
+ causing lines to be parsed as observations.
+ """
+ # HTML color code in font tag should NOT be an observation
+ token = Token("inline", '**Jane:** Welcome to the show', 0)
+ assert not is_observation(token), "HTML color codes should not trigger hashtag detection"
+
+ # Color code in style attribute
+ token = Token("inline", 'Styled text', 0)
+ assert not is_observation(token), "Color codes in style should not be observations"
+
+ # Multiple color codes
+ token = Token(
+ "inline", 'Blue and Red', 0
+ )
+ assert not is_observation(token), "Multiple color codes should not be observations"
+
+ # Hex color without quotes (edge case)
+ token = Token("inline", "background-color:#FFFFFF is white", 0)
+ assert not is_observation(token), "Inline hex colors should not be observations"
+
+ # But standalone hashtags SHOULD still work
+ token = Token("inline", "This has a #realtag in it", 0)
+ assert is_observation(token), "Standalone hashtags should still work"
+
+ # Multiple real hashtags
+ token = Token("inline", "Tags: #design #feature #important", 0)
+ assert is_observation(token), "Multiple standalone hashtags should work"
+
+ # Mix of color code and real tag - should be observation because of real tag
+ token = Token("inline", 'Text #actualtag', 0)
+ assert is_observation(token), "Real hashtag with color code should still be observation"
+
+
def test_relation_plugin():
"""Test relation plugin."""
md = MarkdownIt().use(relation_plugin)
diff --git a/tests/repository/test_observation_repository.py b/tests/repository/test_observation_repository.py
index aed3eaef..57331870 100644
--- a/tests/repository/test_observation_repository.py
+++ b/tests/repository/test_observation_repository.py
@@ -356,3 +356,95 @@ async def test_find_by_category_case_sensitivity(
upper_case = await repo.find_by_category("TECH")
assert len(upper_case) == 0 # Currently case-sensitive
+
+
+@pytest.mark.asyncio
+async def test_observation_permalink_truncates_long_content(
+ session_maker: async_sessionmaker, repo, test_project: Project
+):
+ """Test that observation permalinks truncate long content.
+
+ This test validates the fix for issue #446 where:
+ - Long observation content (like transcript dialogue) created permalinks
+ exceeding PostgreSQL's btree index limit of 2704 bytes.
+ - Content is now truncated to 200 chars in the permalink property.
+ """
+ async with db.scoped_session(session_maker) as session:
+ entity = Entity(
+ project_id=test_project.id,
+ title="test_entity",
+ entity_type="test",
+ permalink="test/test-entity",
+ file_path="test/test_entity.md",
+ content_type="text/markdown",
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+ session.add(entity)
+ await session.flush()
+
+ # Create observation with very long content (5000+ chars to simulate transcript)
+ long_content = "A" * 5000 # Well over the 200 char limit
+ obs = Observation(
+ entity_id=entity.id,
+ content=long_content,
+ category="transcript",
+ )
+ session.add(obs)
+ await session.flush()
+
+ # Access the permalink property
+ permalink = obs.permalink
+
+ # The full content would create a permalink like:
+ # test/test-entity/observations/transcript/AAAA...5000 chars
+ # With truncation, it should be much shorter
+
+ # Content portion should be truncated to 200 chars
+ # Permalink format: entity_permalink/observations/category/content
+ assert len(permalink) < 300 # Should be well under 300 chars total
+ assert len(long_content[:200]) == 200 # Verify truncation length
+
+ # Verify the permalink contains expected parts
+ assert "test/test-entity" in permalink or "test-entity" in permalink
+ assert "observations" in permalink
+ assert "transcript" in permalink
+
+ # Full 5000-char content should NOT be in permalink
+ assert long_content not in permalink
+
+
+@pytest.mark.asyncio
+async def test_observation_permalink_short_content_unchanged(
+ session_maker: async_sessionmaker, repo, test_project: Project
+):
+ """Test that short observation content is not unnecessarily truncated."""
+ async with db.scoped_session(session_maker) as session:
+ entity = Entity(
+ project_id=test_project.id,
+ title="test_entity",
+ entity_type="test",
+ permalink="test/test-entity",
+ file_path="test/test_entity.md",
+ content_type="text/markdown",
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+ session.add(entity)
+ await session.flush()
+
+ # Create observation with short content
+ short_content = "Short observation content"
+ obs = Observation(
+ entity_id=entity.id,
+ content=short_content,
+ category="note",
+ )
+ session.add(obs)
+ await session.flush()
+
+ permalink = obs.permalink
+
+ # Short content should be fully included (after permalink normalization)
+ # The generate_permalink function normalizes the content
+ assert "short-observation-content" in permalink.lower()