Skip to content

Commit 95e1790

Browse files
author
phernandez
committed
fix: re-add feature to read multiple notes
1 parent 666f869 commit 95e1790

File tree

11 files changed

+385
-102
lines changed

11 files changed

+385
-102
lines changed
Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,115 @@
11
"""Routes for getting entity content."""
22

3+
import tempfile
34
from pathlib import Path
45

5-
from fastapi import APIRouter, HTTPException
6+
from fastapi import APIRouter, HTTPException, BackgroundTasks
67
from fastapi.responses import FileResponse
78
from loguru import logger
89

9-
from basic_memory.deps import ProjectConfigDep, LinkResolverDep
10+
from basic_memory.deps import (
11+
ProjectConfigDep,
12+
LinkResolverDep,
13+
SearchServiceDep,
14+
EntityServiceDep,
15+
FileServiceDep,
16+
)
17+
from basic_memory.repository.search_repository import SearchIndexRow
18+
from basic_memory.schemas.memory import normalize_memory_url
19+
from basic_memory.schemas.search import SearchQuery, SearchItemType
1020

1121
router = APIRouter(prefix="/resource", tags=["resources"])
1222

1323

24+
def get_entity_ids(item: SearchIndexRow) -> list[int]:
25+
match item.type:
26+
case SearchItemType.ENTITY:
27+
return [item.id]
28+
case SearchItemType.OBSERVATION:
29+
return [item.entity_id]
30+
case SearchItemType.RELATION:
31+
from_entity = item.from_id
32+
to_entity = item.to_id
33+
return [from_entity, to_entity] if to_entity else [from_entity]
34+
35+
1436
@router.get("/{identifier:path}")
1537
async def get_resource_content(
1638
config: ProjectConfigDep,
1739
link_resolver: LinkResolverDep,
40+
search_service: SearchServiceDep,
41+
entity_service: EntityServiceDep,
42+
file_service: FileServiceDep,
43+
background_tasks: BackgroundTasks,
1844
identifier: str,
1945
) -> FileResponse:
2046
"""Get resource content by identifier: name or permalink."""
21-
logger.debug(f"Getting content for permalink: {identifier}")
47+
logger.debug(f"Getting content for: {identifier}")
2248

23-
# Find entity by permalink
49+
# Find single entity by permalink
2450
entity = await link_resolver.resolve_link(identifier)
25-
if not entity:
26-
raise HTTPException(status_code=404, detail=f"Entity not found: {identifier}")
27-
28-
file_path = Path(f"{config.home}/{entity.file_path}")
29-
if not file_path.exists():
30-
raise HTTPException(
31-
status_code=404,
32-
detail=f"File not found: {file_path}",
51+
results = [entity] if entity else []
52+
53+
# search using the identifier as a permalink
54+
if not results:
55+
# if the identifier contains a wildcard, use GLOB search
56+
query = (
57+
SearchQuery(permalink_match=identifier)
58+
if "*" in identifier
59+
else SearchQuery(permalink=identifier)
3360
)
34-
return FileResponse(path=file_path)
61+
search_results = await search_service.search(query)
62+
if not search_results:
63+
raise HTTPException(status_code=404, detail=f"Resource not found: {identifier}")
64+
65+
# get the entities related to the search results
66+
entity_ids = [id for result in search_results for id in get_entity_ids(result)]
67+
results = await entity_service.get_entities_by_id(entity_ids)
68+
69+
# return single response
70+
if len(results) == 1:
71+
entity = results[0]
72+
file_path = Path(f"{config.home}/{entity.file_path}")
73+
if not file_path.exists():
74+
raise HTTPException(
75+
status_code=404,
76+
detail=f"File not found: {file_path}",
77+
)
78+
return FileResponse(path=file_path)
79+
80+
# for multiple files, initialize a temporary file for writing the results
81+
with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
82+
temp_file_path = tmp_file.name
83+
84+
for result in results:
85+
# Read content for each entity
86+
content = await file_service.read_entity_content(result)
87+
memory_url = normalize_memory_url(result.permalink)
88+
modified_date = result.updated_at.isoformat()
89+
checksum = result.checksum[:8]
90+
91+
# Prepare the delimited content
92+
response_content = f"--- {memory_url} {modified_date} {checksum}\n"
93+
response_content += f"\n{content}\n"
94+
response_content += "\n"
95+
96+
# Write content directly to the temporary file in append mode
97+
tmp_file.write(response_content)
98+
99+
# Ensure all content is written to disk
100+
tmp_file.flush()
101+
102+
# Schedule the temporary file to be deleted after the response
103+
background_tasks.add_task(cleanup_temp_file, temp_file_path)
104+
105+
# Return the file response
106+
return FileResponse(path=temp_file_path)
107+
108+
109+
def cleanup_temp_file(file_path: str):
110+
"""Delete the temporary file."""
111+
try:
112+
Path(file_path).unlink() # Deletes the file
113+
logger.debug(f"Temporary file deleted: {file_path}")
114+
except Exception as e: # pragma: no cover
115+
logger.error(f"Error deleting temporary file {file_path}: {e}")

src/basic_memory/mcp/tools/notes.py

Lines changed: 85 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,29 @@ async def write_note(
2828
) -> EntityResponse | str:
2929
"""Write a markdown note to the knowledge base.
3030
31+
The content can include semantic observations and relations using markdown syntax.
32+
Relations can be specified either explicitly or through inline wiki-style links:
33+
34+
Observations format:
35+
`- [category] Observation text #tag1 #tag2 (optional context)`
36+
37+
Examples:
38+
`- [design] Files are the source of truth #architecture (All state comes from files)`
39+
`- [tech] Using SQLite for storage #implementation`
40+
`- [note] Need to add error handling #todo`
41+
42+
Relations format:
43+
- Explicit: `- relation_type [[Entity]] (optional context)`
44+
- Inline: Any `[[Entity]]` reference creates a relation
45+
46+
Examples:
47+
`- depends_on [[Content Parser]] (Need for semantic extraction)`
48+
`- implements [[Search Spec]] (Initial implementation)`
49+
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
50+
3151
Args:
3252
title: The title of the note
33-
content: Markdown content for the note
53+
content: Markdown content for the note, can include observations and relations
3454
folder: the folder where the file should be saved
3555
tags: Optional list of tags to categorize the note
3656
verbose: If True, returns full EntityResponse with semantic info
@@ -40,19 +60,32 @@ async def write_note(
4060
If verbose=True: EntityResponse with full semantic details
4161
4262
Examples:
43-
# Create a simple note
63+
# Note with both explicit and inline relations
4464
write_note(
45-
tile="Meeting Notes: Project Planning.md",
46-
content="# Key Points\\n\\n- Discussed timeline\\n- Set priorities"
47-
folder="notes"
65+
title="Search Implementation",
66+
content="# Search Component\\n\\n"
67+
"Implementation of the search feature, building on [[Core Search]].\\n\\n"
68+
"## Observations\\n"
69+
"- [tech] Using FTS5 for full-text search #implementation\\n"
70+
"- [design] Need pagination support #todo\\n\\n"
71+
"## Relations\\n"
72+
"- implements [[Search Spec]]\\n"
73+
"- depends_on [[Database Schema]]",
74+
folder="docs/components"
4875
)
4976
50-
# Create note with tags
77+
# Note with tags
5178
write_note(
52-
title="Security Review",
53-
content="# Findings\\n\\n1. Updated auth flow\\n2. Added rate limiting",
54-
folder="security",
55-
tags=["security", "development"]
79+
title="Error Handling Design",
80+
content="# Error Handling\\n\\n"
81+
"This design builds on [[Reliability Design]].\\n\\n"
82+
"## Approach\\n"
83+
"- [design] Use error codes #architecture\\n"
84+
"- [tech] Implement retry logic #implementation\\n\\n"
85+
"## Relations\\n"
86+
"- extends [[Base Error Handling]]",
87+
folder="docs/design",
88+
tags=["architecture", "reliability"]
5689
)
5790
"""
5891
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
@@ -76,26 +109,58 @@ async def write_note(
76109
return result if verbose else result.permalink
77110

78111

79-
@mcp.tool(description="Read a note's content by its title or permalink")
112+
@mcp.tool(description="Read note content by title, permalink, relation, or pattern")
80113
async def read_note(identifier: str) -> str:
81-
"""Get the markdown content of a note.
82-
Uses the resource router to return the actual file content.
114+
"""Get note content in unified diff format.
115+
116+
The content is returned in a unified diff inspired format:
117+
```
118+
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
119+
<document content>
120+
```
121+
122+
Multiple documents (from relations or pattern matches) are separated by
123+
additional headers.
83124
84125
Args:
85-
identifier: Note title or permalink
126+
identifier: Can be one of:
127+
- Note title ("Project Planning")
128+
- Note permalink ("docs/example")
129+
- Relation path ("docs/example/depends-on/other-doc")
130+
- Pattern match ("docs/*-architecture")
86131
87132
Returns:
88-
The note's markdown content
133+
Document content in unified diff format. For single documents, returns
134+
just that document's content. For relations or pattern matches, returns
135+
multiple documents separated by unified diff headers.
89136
90137
Examples:
91-
# Read by title
92-
read_note("Meeting Notes: Project Planning")
138+
# Single document
139+
content = await read_note("Project Planning")
93140
94141
# Read by permalink
95-
read_note("notes/project-planning")
142+
content = await read_note("docs/architecture/file-first")
143+
144+
# Follow relation
145+
content = await read_note("docs/architecture/depends-on/docs/content-parser")
146+
147+
# Pattern matching
148+
content = await read_note("docs/*-architecture") # All architecture docs
149+
content = await read_note("docs/*/implements/*") # Find implementations
150+
151+
Output format:
152+
```
153+
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
154+
<first document content>
155+
156+
--- memory://docs/other 2025-01-30T15:45:22 a1b2c3d4
157+
<second document content>
158+
```
96159
97-
Raises:
98-
ValueError: If the note cannot be found
160+
The headers include:
161+
- Full memory:// URI for the document
162+
- Last modified timestamp
163+
- Content checksum
99164
"""
100165
logger.info(f"Reading note {identifier}")
101166
url = memory_url_path(identifier)

src/basic_memory/mcp/tools/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,4 @@ async def call_delete(
151151
return response
152152
except HTTPStatusError as e:
153153
logger.error(f"Error calling DELETE {url}: {e}")
154-
raise ToolError(f"Error calling tool: {e}") from e
154+
raise ToolError(f"Error calling tool: {e}") from e

src/basic_memory/repository/search_repository.py

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ async def init_search_index(self):
7373
async with db.scoped_session(self.session_maker) as session:
7474
await session.execute(CREATE_SEARCH_INDEX)
7575
await session.commit()
76-
except Exception as e:
76+
except Exception as e: # pragma: no cover
7777
logger.error(f"Error initializing search index: {e}")
7878
raise e
7979

@@ -86,31 +86,22 @@ def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
8686
8787
For FTS5:
8888
- Special characters and phrases need to be quoted
89-
- Prefix searches (trailing *) need special handling
9089
- Terms with spaces or special chars need quotes
9190
"""
91+
if "*" in term:
92+
return term
93+
9294
# List of special characters that need quoting (excluding *)
9395
special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
94-
95-
# Handle trailing wildcard
96-
has_trailing_wildcard = term.endswith('*')
97-
if has_trailing_wildcard:
98-
term = term[:-1] # Remove trailing * for processing
99-
96+
10097
# Check if term contains any special characters
10198
needs_quotes = any(c in term for c in special_chars)
10299

103100
if needs_quotes:
104-
# If the term already contains quotes, escape them
101+
# If the term already contains quotes, escape them and add a wildcard
105102
term = term.replace('"', '""')
106-
term = f'"{term}"'
107-
108-
# Add prefix search capability
109-
if is_prefix and not has_trailing_wildcard:
110-
term = f"{term}*"
111-
elif has_trailing_wildcard:
112-
term = f"{term}*"
113-
103+
term = f'"{term}"*'
104+
114105
return term
115106

116107
async def search(
@@ -131,13 +122,13 @@ async def search(
131122

132123
# Handle text search for title and content
133124
if search_text:
134-
search_text = self._prepare_search_term(search_text.lower().strip())
125+
search_text = self._prepare_search_term(search_text.strip())
135126
params["text"] = search_text
136127
conditions.append("(title MATCH :text OR content MATCH :text)")
137128

138129
# Handle title match search
139130
if title:
140-
title_text = self._prepare_search_term(title.lower().strip())
131+
title_text = self._prepare_search_term(title.strip())
141132
params["text"] = title_text
142133
conditions.append("title MATCH :text")
143134

@@ -148,10 +139,14 @@ async def search(
148139

149140
# Handle permalink match search, supports *
150141
if permalink_match:
151-
# Clean and prepare permalink for FTS5 pattern match
142+
# Clean and prepare permalink for FTS5 GLOB match
152143
permalink_text = self._prepare_search_term(permalink_match.lower().strip(), is_prefix=False)
153144
params["permalink"] = permalink_text
154-
conditions.append("permalink GLOB :permalink")
145+
if "*" in permalink_match:
146+
conditions.append("permalink GLOB :permalink")
147+
else:
148+
conditions.append("permalink MATCH :permalink")
149+
155150

156151
# Handle type filter
157152
if types:
@@ -200,7 +195,7 @@ async def search(
200195
LIMIT :limit
201196
"""
202197

203-
# logger.debug(f"Search {sql} params: {params}")
198+
logger.debug(f"Search {sql} params: {params}")
204199
async with db.scoped_session(self.session_maker) as session:
205200
result = await session.execute(text(sql), params)
206201
rows = result.fetchall()
@@ -226,8 +221,9 @@ async def search(
226221
for row in rows
227222
]
228223

229-
# for r in results:
230-
# logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
224+
logger.debug(f"Found {len(results)} search results")
225+
for r in results:
226+
logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
231227

232228
return results
233229

@@ -260,7 +256,7 @@ async def index_item(
260256
"""),
261257
search_index_row.to_insert(),
262258
)
263-
logger.debug(f"indexed permalink {search_index_row.permalink}")
259+
logger.debug(f"indexed row {search_index_row}")
264260
await session.commit()
265261

266262
async def delete_by_permalink(self, permalink: str):

0 commit comments

Comments
 (0)