Skip to content

Commit 39bd5ca

Browse files
phernandezphernandez
andauthored
fix: Re do enhanced read note format (#10)
Co-authored-by: phernandez <phernandez@basicmachines.co>
1 parent 4a1f545 commit 39bd5ca

File tree

15 files changed

+477
-136
lines changed

15 files changed

+477
-136
lines changed
Lines changed: 97 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,118 @@
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] # pyright: ignore [reportReturnType]
30+
case SearchItemType.RELATION:
31+
from_entity = item.from_id
32+
to_entity = item.to_id # pyright: ignore [reportReturnType]
33+
return [from_entity, to_entity] if to_entity else [from_entity] # pyright: ignore [reportReturnType]
34+
case _:
35+
raise ValueError(f"Unexpected type: {item.type}")
36+
37+
1438
@router.get("/{identifier:path}")
1539
async def get_resource_content(
1640
config: ProjectConfigDep,
1741
link_resolver: LinkResolverDep,
42+
search_service: SearchServiceDep,
43+
entity_service: EntityServiceDep,
44+
file_service: FileServiceDep,
45+
background_tasks: BackgroundTasks,
1846
identifier: str,
1947
) -> FileResponse:
2048
"""Get resource content by identifier: name or permalink."""
21-
logger.debug(f"Getting content for permalink: {identifier}")
49+
logger.debug(f"Getting content for: {identifier}")
2250

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

src/basic_memory/cli/commands/import_chatgpt.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ def traverse_messages(
6969

7070

7171
def format_chat_markdown(
72-
title: str, mapping: Dict[str, Any], root_id: Optional[str], created_at: float, modified_at: float
72+
title: str,
73+
mapping: Dict[str, Any],
74+
root_id: Optional[str],
75+
created_at: float,
76+
modified_at: float,
7377
) -> str:
7478
"""Format chat as clean markdown."""
7579

src/basic_memory/db.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,15 @@ async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.F
140140

141141
# Set required Alembic config options programmatically
142142
config.set_main_option("script_location", str(alembic_dir))
143-
config.set_main_option("file_template",
144-
"%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s")
143+
config.set_main_option(
144+
"file_template",
145+
"%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s",
146+
)
145147
config.set_main_option("timezone", "UTC")
146148
config.set_main_option("revision_environment", "false")
147-
config.set_main_option("sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type))
149+
config.set_main_option(
150+
"sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type)
151+
)
148152

149153
command.upgrade(config, "head")
150154
logger.info("Migrations completed successfully")
@@ -153,4 +157,4 @@ async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.F
153157
await SearchRepository(session_maker).init_search_index()
154158
except Exception as e: # pragma: no cover
155159
logger.error(f"Error running migrations: {e}")
156-
raise
160+
raise

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)

0 commit comments

Comments
 (0)