Skip to content

Commit d7b8f37

Browse files
fix: normalize underscores in memory:// URLs for build_context
- Convert underscores to hyphens when parsing memory:// URLs to match stored permalinks - Ensures both underscore and hyphenated formats work consistently - Added comprehensive integration tests for underscore normalization Fixes #304 Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
1 parent ee83b0e commit d7b8f37

2 files changed

Lines changed: 174 additions & 6 deletions

File tree

src/basic_memory/services/context_service.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,20 @@ async def build_context(
102102

103103
if memory_url:
104104
path = memory_url_path(memory_url)
105+
# Normalize the path by converting underscores to hyphens to match stored permalinks
106+
normalized_path = generate_permalink(path, split_extension=False)
105107
# Pattern matching - use search
106-
if "*" in path:
107-
logger.debug(f"Pattern search for '{path}'")
108+
if "*" in normalized_path:
109+
logger.debug(f"Pattern search for '{normalized_path}'")
108110
primary = await self.search_repository.search(
109-
permalink_match=path, limit=limit, offset=offset
111+
permalink_match=normalized_path, limit=limit, offset=offset
110112
)
111113

112114
# Direct lookup for exact path
113115
else:
114-
logger.debug(f"Direct lookup for '{path}'")
116+
logger.debug(f"Direct lookup for '{normalized_path}'")
115117
primary = await self.search_repository.search(
116-
permalink=path, limit=limit, offset=offset
118+
permalink=normalized_path, limit=limit, offset=offset
117119
)
118120
else:
119121
logger.debug(f"Build context for '{types}'")
@@ -151,7 +153,7 @@ async def build_context(
151153

152154
# Create metadata dataclass
153155
metadata = ContextMetadata(
154-
uri=memory_url_path(memory_url) if memory_url else None,
156+
uri=normalized_path if memory_url else None,
155157
types=types,
156158
depth=depth,
157159
timeframe=since.isoformat() if since else None,
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Integration test for build_context with underscore in memory:// URLs."""
2+
3+
import pytest
4+
from fastmcp import Client
5+
6+
7+
@pytest.mark.asyncio
8+
async def test_build_context_underscore_normalization(mcp_server, app, test_project):
9+
"""Test that build_context normalizes underscores in relation types."""
10+
11+
async with Client(mcp_server) as client:
12+
# Create parent note
13+
await client.call_tool(
14+
"write_note",
15+
{
16+
"project": test_project.name,
17+
"title": "Parent Entity",
18+
"folder": "testing",
19+
"content": "# Parent Entity\n\nMain entity for testing underscore relations.",
20+
"tags": "test,parent",
21+
},
22+
)
23+
24+
# Create child notes with different relation formats
25+
await client.call_tool(
26+
"write_note",
27+
{
28+
"project": test_project.name,
29+
"title": "Child with Underscore",
30+
"folder": "testing",
31+
"content": """# Child with Underscore
32+
33+
- part_of [[Parent Entity]]
34+
- related_to [[Parent Entity]]
35+
""",
36+
"tags": "test,child",
37+
},
38+
)
39+
40+
await client.call_tool(
41+
"write_note",
42+
{
43+
"project": test_project.name,
44+
"title": "Child with Hyphen",
45+
"folder": "testing",
46+
"content": """# Child with Hyphen
47+
48+
- part-of [[Parent Entity]]
49+
- related-to [[Parent Entity]]
50+
""",
51+
"tags": "test,child",
52+
},
53+
)
54+
55+
# Test 1: Search with underscore format should return results
56+
result_underscore = await client.call_tool(
57+
"build_context",
58+
{
59+
"project": test_project.name,
60+
"url": "memory://testing/parent-entity/part_of/*", # Using underscore
61+
},
62+
)
63+
64+
# Parse response
65+
assert len(result_underscore.content) == 1
66+
response_text = result_underscore.content[0].text # pyright: ignore
67+
assert '"results"' in response_text
68+
69+
# Both children should be found since they both have part_of/part-of relations
70+
# The system should normalize the underscore to hyphen internally
71+
assert "Child with Underscore" in response_text or "child-with-underscore" in response_text
72+
assert "Child with Hyphen" in response_text or "child-with-hyphen" in response_text
73+
74+
# Test 2: Search with hyphen format should also return results
75+
result_hyphen = await client.call_tool(
76+
"build_context",
77+
{
78+
"project": test_project.name,
79+
"url": "memory://testing/parent-entity/part-of/*", # Using hyphen
80+
},
81+
)
82+
83+
response_text_hyphen = result_hyphen.content[0].text # pyright: ignore
84+
assert '"results"' in response_text_hyphen
85+
assert "Child with Underscore" in response_text_hyphen or "child-with-underscore" in response_text_hyphen
86+
assert "Child with Hyphen" in response_text_hyphen or "child-with-hyphen" in response_text_hyphen
87+
88+
# Test 3: Test with related_to/related-to as well
89+
result_related = await client.call_tool(
90+
"build_context",
91+
{
92+
"project": test_project.name,
93+
"url": "memory://testing/parent-entity/related_to/*", # Using underscore
94+
},
95+
)
96+
97+
response_text_related = result_related.content[0].text # pyright: ignore
98+
assert '"results"' in response_text_related
99+
100+
# Test 4: Test exact path (non-wildcard) with underscore
101+
result_exact = await client.call_tool(
102+
"build_context",
103+
{
104+
"project": test_project.name,
105+
"url": "memory://testing/parent-entity/part_of/child-with-underscore",
106+
},
107+
)
108+
109+
response_text_exact = result_exact.content[0].text # pyright: ignore
110+
assert '"results"' in response_text_exact
111+
112+
113+
@pytest.mark.asyncio
114+
async def test_build_context_complex_underscore_paths(mcp_server, app, test_project):
115+
"""Test build_context with complex paths containing underscores."""
116+
117+
async with Client(mcp_server) as client:
118+
# Create notes with underscores in titles and relations
119+
await client.call_tool(
120+
"write_note",
121+
{
122+
"project": test_project.name,
123+
"title": "workflow_manager_agent",
124+
"folder": "specs",
125+
"content": """# Workflow Manager Agent
126+
127+
Specification for the workflow manager agent.
128+
""",
129+
"tags": "spec,workflow",
130+
},
131+
)
132+
133+
await client.call_tool(
134+
"write_note",
135+
{
136+
"project": test_project.name,
137+
"title": "task_parser",
138+
"folder": "components",
139+
"content": """# Task Parser
140+
141+
- part_of [[workflow_manager_agent]]
142+
- implements_for [[workflow_manager_agent]]
143+
""",
144+
"tags": "component,parser",
145+
},
146+
)
147+
148+
# Test with underscores in all parts of the path
149+
test_cases = [
150+
"memory://specs/workflow_manager_agent/part_of/*",
151+
"memory://specs/workflow-manager-agent/part_of/*",
152+
"memory://specs/workflow_manager_agent/part-of/*",
153+
"memory://specs/workflow-manager-agent/part-of/*",
154+
]
155+
156+
for url in test_cases:
157+
result = await client.call_tool(
158+
"build_context", {"project": test_project.name, "url": url}
159+
)
160+
161+
# All variations should work and find the related content
162+
assert len(result.content) == 1
163+
response = result.content[0].text # pyright: ignore
164+
assert '"results"' in response
165+
# The task_parser should be found in all cases
166+
assert "task" in response.lower() and "parser" in response.lower(), f"Failed for URL: {url}"

0 commit comments

Comments
 (0)