Skip to content

Commit 78673d8

Browse files
phernandezclaude
andauthored
chore: Cloud compatibility fixes and performance improvements (#454)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 126c049 commit 78673d8

18 files changed

Lines changed: 565 additions & 244 deletions

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,39 @@ See the [Documentation](https://memory.basicmachines.co/) for more info, includi
433433
- [Managing multiple Projects](https://docs.basicmemory.com/guides/cli-reference/#project)
434434
- [Importing data from OpenAI/Claude Projects](https://docs.basicmemory.com/guides/cli-reference/#import)
435435

436+
## Logging
437+
438+
Basic Memory uses [Loguru](https://github.com/Delgan/loguru) for logging. The logging behavior varies by entry point:
439+
440+
| Entry Point | Default Behavior | Use Case |
441+
|-------------|------------------|----------|
442+
| CLI commands | File only | Prevents log output from interfering with command output |
443+
| MCP server | File only | Stdout would corrupt the JSON-RPC protocol |
444+
| API server | File (local) or stdout (cloud) | Docker/cloud deployments use stdout |
445+
446+
**Log file location:** `~/.basic-memory/basic-memory.log` (10MB rotation, 10 days retention)
447+
448+
### Environment Variables
449+
450+
| Variable | Default | Description |
451+
|----------|---------|-------------|
452+
| `BASIC_MEMORY_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
453+
| `BASIC_MEMORY_CLOUD_MODE` | `false` | When `true`, API logs to stdout with structured context |
454+
| `BASIC_MEMORY_ENV` | `dev` | Set to `test` for test mode (stderr only) |
455+
456+
### Examples
457+
458+
```bash
459+
# Enable debug logging
460+
BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory sync
461+
462+
# View logs
463+
tail -f ~/.basic-memory/basic-memory.log
464+
465+
# Cloud/Docker mode (stdout logging with structured context)
466+
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
467+
```
468+
436469
## Development
437470

438471
### Running Tests

src/basic_memory/api/app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,17 @@
3030
prompt_router as v2_prompt,
3131
importer_router as v2_importer,
3232
)
33-
from basic_memory.config import ConfigManager
33+
from basic_memory.config import ConfigManager, init_api_logging
3434
from basic_memory.services.initialization import initialize_file_sync, initialize_app
3535

3636

3737
@asynccontextmanager
3838
async def lifespan(app: FastAPI): # pragma: no cover
3939
"""Lifecycle manager for the FastAPI app. Not called in stdio mcp mode"""
4040

41+
# Initialize logging for API (stdout in cloud mode, file otherwise)
42+
init_api_logging()
43+
4144
app_config = ConfigManager().config
4245
logger.info("Starting Basic Memory API")
4346

src/basic_memory/api/routers/resource_router.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
import tempfile
44
from pathlib import Path
5-
from typing import Annotated
5+
from typing import Annotated, Union
66

7-
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body
7+
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body, Response
88
from fastapi.responses import FileResponse, JSONResponse
99
from loguru import logger
1010

@@ -50,7 +50,7 @@ def get_entity_ids(item: SearchIndexRow) -> set[int]:
5050
raise ValueError(f"Unexpected type: {item.type}")
5151

5252

53-
@router.get("/{identifier:path}")
53+
@router.get("/{identifier:path}", response_model=None)
5454
async def get_resource_content(
5555
config: ProjectConfigDep,
5656
link_resolver: LinkResolverDep,
@@ -61,7 +61,7 @@ async def get_resource_content(
6161
identifier: str,
6262
page: int = 1,
6363
page_size: int = 10,
64-
) -> FileResponse:
64+
) -> Union[Response, FileResponse]:
6565
"""Get resource content by identifier: name or permalink."""
6666
logger.debug(f"Getting content for: {identifier}")
6767

@@ -92,13 +92,16 @@ async def get_resource_content(
9292
# return single response
9393
if len(results) == 1:
9494
entity = results[0]
95-
file_path = Path(f"{config.home}/{entity.file_path}")
96-
if not file_path.exists():
95+
# Check file exists via file_service (for cloud compatibility)
96+
if not await file_service.exists(entity.file_path):
9797
raise HTTPException(
9898
status_code=404,
99-
detail=f"File not found: {file_path}",
99+
detail=f"File not found: {entity.file_path}",
100100
)
101-
return FileResponse(path=file_path)
101+
# Read content via file_service as bytes (works with both local and S3)
102+
content = await file_service.read_file_bytes(entity.file_path)
103+
content_type = file_service.content_type(entity.file_path)
104+
return Response(content=content, media_type=content_type)
102105

103106
# for multiple files, initialize a temporary file for writing the results
104107
with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
@@ -192,7 +195,7 @@ async def write_resource(
192195
checksum = await file_service.write_file(full_path, content_str)
193196

194197
# Get file info
195-
file_stats = file_service.file_stats(full_path)
198+
file_metadata = await file_service.get_file_metadata(full_path)
196199

197200
# Determine file details
198201
file_name = Path(file_path).name
@@ -213,7 +216,7 @@ async def write_resource(
213216
"content_type": content_type,
214217
"file_path": file_path,
215218
"checksum": checksum,
216-
"updated_at": datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
219+
"updated_at": file_metadata.modified_at,
217220
},
218221
)
219222
status_code = 200
@@ -225,8 +228,8 @@ async def write_resource(
225228
content_type=content_type,
226229
file_path=file_path,
227230
checksum=checksum,
228-
created_at=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
229-
updated_at=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
231+
created_at=file_metadata.created_at,
232+
updated_at=file_metadata.modified_at,
230233
)
231234
entity = await entity_repository.add(entity)
232235
status_code = 201
@@ -240,9 +243,9 @@ async def write_resource(
240243
content={
241244
"file_path": file_path,
242245
"checksum": checksum,
243-
"size": file_stats.st_size,
244-
"created_at": file_stats.st_ctime,
245-
"modified_at": file_stats.st_mtime,
246+
"size": file_metadata.size,
247+
"created_at": file_metadata.created_at.timestamp(),
248+
"modified_at": file_metadata.modified_at.timestamp(),
246249
},
247250
)
248251
except Exception as e: # pragma: no cover

src/basic_memory/api/routers/utils.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,24 @@ async def to_graph_context(
2424
page: Optional[int] = None,
2525
page_size: Optional[int] = None,
2626
):
27+
# First pass: collect all entity IDs needed for relations
28+
entity_ids_needed: set[int] = set()
29+
for context_item in context_result.results:
30+
for item in [context_item.primary_result] + context_item.observations + context_item.related_results:
31+
if item.type == SearchItemType.RELATION:
32+
if item.from_id: # pyright: ignore
33+
entity_ids_needed.add(item.from_id) # pyright: ignore
34+
if item.to_id:
35+
entity_ids_needed.add(item.to_id)
36+
37+
# Batch fetch all entities at once
38+
entity_lookup: dict[int, str] = {}
39+
if entity_ids_needed:
40+
entities = await entity_repository.find_by_ids(list(entity_ids_needed))
41+
entity_lookup = {e.id: e.title for e in entities}
42+
2743
# Helper function to convert items to summaries
28-
async def to_summary(item: SearchIndexRow | ContextResultRow):
44+
def to_summary(item: SearchIndexRow | ContextResultRow):
2945
match item.type:
3046
case SearchItemType.ENTITY:
3147
return EntitySummary(
@@ -48,18 +64,18 @@ async def to_summary(item: SearchIndexRow | ContextResultRow):
4864
created_at=item.created_at,
4965
)
5066
case SearchItemType.RELATION:
51-
from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore
52-
to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
67+
from_title = entity_lookup.get(item.from_id) if item.from_id else None # pyright: ignore
68+
to_title = entity_lookup.get(item.to_id) if item.to_id else None
5369
return RelationSummary(
5470
relation_id=item.id,
5571
entity_id=item.entity_id, # pyright: ignore
5672
title=item.title, # pyright: ignore
5773
file_path=item.file_path,
5874
permalink=item.permalink, # pyright: ignore
5975
relation_type=item.relation_type, # pyright: ignore
60-
from_entity=from_entity.title if from_entity else None,
76+
from_entity=from_title,
6177
from_entity_id=item.from_id, # pyright: ignore
62-
to_entity=to_entity.title if to_entity else None,
78+
to_entity=to_title,
6379
to_entity_id=item.to_id,
6480
created_at=item.created_at,
6581
)
@@ -70,23 +86,19 @@ async def to_summary(item: SearchIndexRow | ContextResultRow):
7086
hierarchical_results = []
7187
for context_item in context_result.results:
7288
# Process primary result
73-
primary_result = await to_summary(context_item.primary_result)
89+
primary_result = to_summary(context_item.primary_result)
7490

75-
# Process observations
76-
observations = []
77-
for obs in context_item.observations:
78-
observations.append(await to_summary(obs))
91+
# Process observations (always ObservationSummary, validated by context_service)
92+
observations = [to_summary(obs) for obs in context_item.observations]
7993

8094
# Process related results
81-
related = []
82-
for rel in context_item.related_results:
83-
related.append(await to_summary(rel))
95+
related = [to_summary(rel) for rel in context_item.related_results]
8496

8597
# Add to hierarchical results
8698
hierarchical_results.append(
8799
ContextResult(
88100
primary_result=primary_result,
89-
observations=observations,
101+
observations=observations, # pyright: ignore[reportArgumentType]
90102
related_results=related,
91103
)
92104
)

src/basic_memory/api/v2/routers/resource_router.py

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@
1111

1212
from pathlib import Path
1313

14-
from fastapi import APIRouter, HTTPException
15-
from fastapi.responses import FileResponse
14+
from fastapi import APIRouter, HTTPException, Response
1615
from loguru import logger
1716

1817
from basic_memory.deps import (
@@ -30,7 +29,6 @@
3029
ResourceResponse,
3130
)
3231
from basic_memory.utils import validate_project_path
33-
from datetime import datetime
3432

3533
router = APIRouter(prefix="/resource", tags=["resources-v2"])
3634

@@ -42,7 +40,7 @@ async def get_resource_content(
4240
config: ProjectConfigV2Dep,
4341
entity_service: EntityServiceV2Dep,
4442
file_service: FileServiceV2Dep,
45-
) -> FileResponse:
43+
) -> Response:
4644
"""Get raw resource content by entity ID.
4745
4846
Args:
@@ -53,7 +51,7 @@ async def get_resource_content(
5351
file_service: File service for reading file content
5452
5553
Returns:
56-
FileResponse with entity content
54+
Response with entity content
5755
5856
Raises:
5957
HTTPException: 404 if entity or file not found
@@ -76,14 +74,18 @@ async def get_resource_content(
7674
detail="Entity contains invalid file path",
7775
)
7876

79-
file_path = Path(f"{config.home}/{entity.file_path}")
80-
if not file_path.exists():
77+
# Check file exists via file_service (for cloud compatibility)
78+
if not await file_service.exists(entity.file_path):
8179
raise HTTPException(
8280
status_code=404,
83-
detail=f"File not found: {file_path}",
81+
detail=f"File not found: {entity.file_path}",
8482
)
8583

86-
return FileResponse(path=file_path)
84+
# Read content via file_service as bytes (works with both local and S3)
85+
content = await file_service.read_file_bytes(entity.file_path)
86+
content_type = file_service.content_type(entity.file_path)
87+
88+
return Response(content=content, media_type=content_type)
8789

8890

8991
@router.post("", response_model=ResourceResponse)
@@ -143,7 +145,7 @@ async def create_resource(
143145
checksum = await file_service.write_file(full_path, data.content)
144146

145147
# Get file info
146-
file_stats = file_service.file_stats(full_path)
148+
file_metadata = await file_service.get_file_metadata(full_path)
147149

148150
# Determine file details
149151
file_name = Path(data.file_path).name
@@ -157,8 +159,8 @@ async def create_resource(
157159
content_type=content_type,
158160
file_path=data.file_path,
159161
checksum=checksum,
160-
created_at=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
161-
updated_at=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
162+
created_at=file_metadata.created_at,
163+
updated_at=file_metadata.modified_at,
162164
)
163165
entity = await entity_repository.add(entity)
164166

@@ -170,9 +172,9 @@ async def create_resource(
170172
entity_id=entity.id,
171173
file_path=data.file_path,
172174
checksum=checksum,
173-
size=file_stats.st_size,
174-
created_at=file_stats.st_ctime,
175-
modified_at=file_stats.st_mtime,
175+
size=file_metadata.size,
176+
created_at=file_metadata.created_at.timestamp(),
177+
modified_at=file_metadata.modified_at.timestamp(),
176178
)
177179
except HTTPException:
178180
# Re-raise HTTP exceptions without wrapping
@@ -233,17 +235,16 @@ async def update_resource(
233235
)
234236

235237
# Get full paths
236-
old_full_path = Path(f"{config.home}/{entity.file_path}")
237238
new_full_path = Path(f"{config.home}/{target_file_path}")
238239

239240
# If moving file, handle the move
240241
if data.file_path and data.file_path != entity.file_path:
241242
# Ensure new parent directory exists
242243
new_full_path.parent.mkdir(parents=True, exist_ok=True)
243244

244-
# If old file exists, remove it
245-
if old_full_path.exists():
246-
old_full_path.unlink()
245+
# If old file exists, remove it via file_service (for cloud compatibility)
246+
if await file_service.exists(entity.file_path):
247+
await file_service.delete_file(entity.file_path)
247248
else:
248249
# Ensure directory exists for in-place update
249250
new_full_path.parent.mkdir(parents=True, exist_ok=True)
@@ -252,7 +253,7 @@ async def update_resource(
252253
checksum = await file_service.write_file(new_full_path, data.content)
253254

254255
# Get file info
255-
file_stats = file_service.file_stats(new_full_path)
256+
file_metadata = await file_service.get_file_metadata(new_full_path)
256257

257258
# Determine file details
258259
file_name = Path(target_file_path).name
@@ -268,7 +269,7 @@ async def update_resource(
268269
"content_type": content_type,
269270
"file_path": target_file_path,
270271
"checksum": checksum,
271-
"updated_at": datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
272+
"updated_at": file_metadata.modified_at,
272273
},
273274
)
274275

@@ -280,9 +281,9 @@ async def update_resource(
280281
entity_id=entity_id,
281282
file_path=target_file_path,
282283
checksum=checksum,
283-
size=file_stats.st_size,
284-
created_at=file_stats.st_ctime,
285-
modified_at=file_stats.st_mtime,
284+
size=file_metadata.size,
285+
created_at=file_metadata.created_at.timestamp(),
286+
modified_at=file_metadata.modified_at.timestamp(),
286287
)
287288
except HTTPException:
288289
# Re-raise HTTP exceptions without wrapping

src/basic_memory/cli/app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import typer
44

5-
from basic_memory.config import ConfigManager
5+
from basic_memory.config import ConfigManager, init_cli_logging
66

77

88
def version_callback(value: bool) -> None:
@@ -31,6 +31,9 @@ def app_callback(
3131
) -> None:
3232
"""Basic Memory - Local-first personal knowledge management."""
3333

34+
# Initialize logging for CLI (file only, no stdout)
35+
init_cli_logging()
36+
3437
# Run initialization for every command unless --version was specified
3538
if not version and ctx.invoked_subcommand is not None:
3639
from basic_memory.services.initialization import ensure_initialization

0 commit comments

Comments
 (0)