Skip to content

Commit f5a11f3

Browse files
phernandezclaudegithub-actions[bot]
authored
fix: normalize underscores in memory:// URLs for build_context (#329)
Signed-off-by: phernandez <[email protected]> Co-authored-by: Claude <[email protected]> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <[email protected]>
1 parent ee83b0e commit f5a11f3

File tree

2 files changed

+190
-9
lines changed

2 files changed

+190
-9
lines changed

src/basic_memory/services/context_service.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,20 +100,30 @@ async def build_context(
100100
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
101101
)
102102

103+
normalized_path: Optional[str] = None
103104
if memory_url:
104105
path = memory_url_path(memory_url)
105-
# Pattern matching - use search
106-
if "*" in path:
107-
logger.debug(f"Pattern search for '{path}'")
106+
# Check for wildcards before normalization
107+
has_wildcard = "*" in path
108+
109+
if has_wildcard:
110+
# For wildcard patterns, normalize each segment separately to preserve the *
111+
parts = path.split("*")
112+
normalized_parts = [
113+
generate_permalink(part, split_extension=False) if part else ""
114+
for part in parts
115+
]
116+
normalized_path = "*".join(normalized_parts)
117+
logger.debug(f"Pattern search for '{normalized_path}'")
108118
primary = await self.search_repository.search(
109-
permalink_match=path, limit=limit, offset=offset
119+
permalink_match=normalized_path, limit=limit, offset=offset
110120
)
111-
112-
# Direct lookup for exact path
113121
else:
114-
logger.debug(f"Direct lookup for '{path}'")
122+
# For exact paths, normalize the whole thing
123+
normalized_path = generate_permalink(path, split_extension=False)
124+
logger.debug(f"Direct lookup for '{normalized_path}'")
115125
primary = await self.search_repository.search(
116-
permalink=path, limit=limit, offset=offset
126+
permalink=normalized_path, limit=limit, offset=offset
117127
)
118128
else:
119129
logger.debug(f"Build context for '{types}'")
@@ -151,7 +161,7 @@ async def build_context(
151161

152162
# Create metadata dataclass
153163
metadata = ContextMetadata(
154-
uri=memory_url_path(memory_url) if memory_url else None,
164+
uri=normalized_path if memory_url else None,
155165
types=types,
156166
depth=depth,
157167
timeframe=since.isoformat() if since else None,
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
# Relation permalinks are: source/relation_type/target
57+
# So child-with-underscore/part-of/parent-entity
58+
result_underscore = await client.call_tool(
59+
"build_context",
60+
{
61+
"project": test_project.name,
62+
"url": "memory://testing/*/part_of/*parent*", # Using underscore
63+
},
64+
)
65+
66+
# Parse response
67+
assert len(result_underscore.content) == 1
68+
response_text = result_underscore.content[0].text # pyright: ignore
69+
assert '"results"' in response_text
70+
71+
# Both relations should be found since they both connect to parent-entity
72+
# The system should normalize the underscore to hyphen internally
73+
assert "part-of" in response_text.lower()
74+
75+
# Test 2: Search with hyphen format should also return results
76+
result_hyphen = await client.call_tool(
77+
"build_context",
78+
{
79+
"project": test_project.name,
80+
"url": "memory://testing/*/part-of/*parent*", # Using hyphen
81+
},
82+
)
83+
84+
response_text_hyphen = result_hyphen.content[0].text # pyright: ignore
85+
assert '"results"' in response_text_hyphen
86+
assert "part-of" in response_text_hyphen.lower()
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/*/related_to/*parent*", # Using underscore
94+
},
95+
)
96+
97+
response_text_related = result_related.content[0].text # pyright: ignore
98+
assert '"results"' in response_text_related
99+
assert "related-to" in response_text_related.lower()
100+
101+
# Test 4: Test exact path (non-wildcard) with underscore
102+
# Exact relation permalink would be child/relation/target
103+
result_exact = await client.call_tool(
104+
"build_context",
105+
{
106+
"project": test_project.name,
107+
"url": "memory://testing/child-with-underscore/part_of/testing/parent-entity",
108+
},
109+
)
110+
111+
response_text_exact = result_exact.content[0].text # pyright: ignore
112+
assert '"results"' in response_text_exact
113+
assert "part-of" in response_text_exact.lower()
114+
115+
116+
@pytest.mark.asyncio
117+
async def test_build_context_complex_underscore_paths(mcp_server, app, test_project):
118+
"""Test build_context with complex paths containing underscores."""
119+
120+
async with Client(mcp_server) as client:
121+
# Create notes with underscores in titles and relations
122+
await client.call_tool(
123+
"write_note",
124+
{
125+
"project": test_project.name,
126+
"title": "workflow_manager_agent",
127+
"folder": "specs",
128+
"content": """# Workflow Manager Agent
129+
130+
Specification for the workflow manager agent.
131+
""",
132+
"tags": "spec,workflow",
133+
},
134+
)
135+
136+
await client.call_tool(
137+
"write_note",
138+
{
139+
"project": test_project.name,
140+
"title": "task_parser",
141+
"folder": "components",
142+
"content": """# Task Parser
143+
144+
- part_of [[workflow_manager_agent]]
145+
- implements_for [[workflow_manager_agent]]
146+
""",
147+
"tags": "component,parser",
148+
},
149+
)
150+
151+
# Test with underscores in all parts of the path
152+
# Relations are created as: task-parser/part-of/workflow-manager-agent
153+
# So search for */part_of/* or */part-of/* to find them
154+
test_cases = [
155+
"memory://components/*/part_of/*workflow*",
156+
"memory://components/*/part-of/*workflow*",
157+
"memory://*/task*/part_of/*",
158+
"memory://*/task*/part-of/*",
159+
]
160+
161+
for url in test_cases:
162+
result = await client.call_tool(
163+
"build_context", {"project": test_project.name, "url": url}
164+
)
165+
166+
# All variations should work and find the related content
167+
assert len(result.content) == 1
168+
response = result.content[0].text # pyright: ignore
169+
assert '"results"' in response
170+
# The relation should be found showing part-of connection
171+
assert "part-of" in response.lower(), f"Failed for URL: {url}"

0 commit comments

Comments
 (0)