Skip to content

Commit 465d4a2

Browse files
committed
Merge branch 'main' into fix/windows-compatibility
Signed-off-by: Manuel Bliemel <manuel.bliemel@gmail.com>
2 parents eacd5d4 + b814d40 commit 465d4a2

16 files changed

Lines changed: 1872 additions & 46 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""fix project foreign keys
2+
3+
Revision ID: a1b2c3d4e5f6
4+
Revises: 647e7a75e2cd
5+
Create Date: 2025-08-19 22:06:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "a1b2c3d4e5f6"
17+
down_revision: Union[str, None] = "647e7a75e2cd"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
"""Re-establish foreign key constraints that were lost during project table recreation.
24+
25+
The migration 647e7a75e2cd recreated the project table but did not re-establish
26+
the foreign key constraint from entity.project_id to project.id, causing
27+
foreign key constraint failures when trying to delete projects with related entities.
28+
"""
29+
# SQLite doesn't allow adding foreign key constraints to existing tables easily
30+
# We need to be careful and handle the case where the constraint might already exist
31+
32+
with op.batch_alter_table("entity", schema=None) as batch_op:
33+
# Try to drop existing foreign key constraint (may not exist)
34+
try:
35+
batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")
36+
except Exception:
37+
# Constraint may not exist, which is fine - we'll create it next
38+
pass
39+
40+
# Add the foreign key constraint with CASCADE DELETE
41+
# This ensures that when a project is deleted, all related entities are also deleted
42+
batch_op.create_foreign_key(
43+
"fk_entity_project_id",
44+
"project",
45+
["project_id"],
46+
["id"],
47+
ondelete="CASCADE"
48+
)
49+
50+
51+
def downgrade() -> None:
52+
"""Remove the foreign key constraint."""
53+
with op.batch_alter_table("entity", schema=None) as batch_op:
54+
batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")

src/basic_memory/mcp/tools/build_context.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Build context tool for Basic Memory MCP server."""
22

3-
from typing import Optional
3+
from typing import Optional, Union
44

55
from loguru import logger
66

@@ -15,6 +15,7 @@
1515
memory_url_path,
1616
)
1717

18+
type StringOrInt = str | int
1819

1920
@mcp.tool(
2021
description="""Build context from a memory:// URI to continue conversations naturally.
@@ -35,7 +36,7 @@
3536
)
3637
async def build_context(
3738
url: MemoryUrl,
38-
depth: Optional[int] = 1,
39+
depth: Optional[StringOrInt] = 1,
3940
timeframe: Optional[TimeFrame] = "7d",
4041
page: int = 1,
4142
page_size: int = 10,
@@ -80,6 +81,15 @@ async def build_context(
8081
build_context("memory://specs/search", project="work-project")
8182
"""
8283
logger.info(f"Building context from {url}")
84+
85+
# Convert string depth to integer if needed
86+
if isinstance(depth, str):
87+
try:
88+
depth = int(depth)
89+
except ValueError:
90+
from mcp.server.fastmcp.exceptions import ToolError
91+
raise ToolError(f"Invalid depth parameter: '{depth}' is not a valid integer")
92+
8393
# URL is already validated and normalized by MemoryUrl type annotation
8494

8595
# Get the active project first to check project-specific sync status

src/basic_memory/mcp/tools/read_note.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
"""Read note tool for Basic Memory MCP server."""
22

33
from textwrap import dedent
4+
from typing import Optional
45

56
from loguru import logger
67

78
from basic_memory.mcp.async_client import client
89
from basic_memory.mcp.server import mcp
910
from basic_memory.mcp.tools.search import search_notes
1011
from basic_memory.mcp.tools.utils import call_get
12+
from basic_memory.mcp.project_session import get_active_project
1113
from basic_memory.schemas.memory import memory_url_path
14+
from basic_memory.utils import validate_project_path
1215

1316

1417
@mcp.tool(
1518
description="Read a markdown note by title or permalink.",
1619
)
17-
async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
20+
async def read_note(
21+
identifier: str, page: int = 1, page_size: int = 10, project: Optional[str] = None
22+
) -> str:
1823
"""Read a markdown note from the knowledge base.
1924
2025
This tool finds and retrieves a note by its title, permalink, or content search,
@@ -26,6 +31,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
2631
Can be a full memory:// URL, a permalink, a title, or search text
2732
page: Page number for paginated results (default: 1)
2833
page_size: Number of items per page (default: 10)
34+
project: Optional project name to read from. If not provided, uses current active project.
2935
3036
Returns:
3137
The full markdown content of the note if found, or helpful guidance if not found.
@@ -42,10 +48,41 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
4248
4349
# Read with pagination
4450
read_note("Project Updates", page=2, page_size=5)
51+
52+
# Read from specific project
53+
read_note("Meeting Notes", project="work-project")
4554
"""
55+
56+
# Get the active project first to check project-specific sync status
57+
active_project = get_active_project(project)
58+
59+
# Validate identifier to prevent path traversal attacks
60+
# We need to check both the raw identifier and the processed path
61+
processed_path = memory_url_path(identifier)
62+
project_path = active_project.home
63+
64+
if not validate_project_path(identifier, project_path) or not validate_project_path(processed_path, project_path):
65+
logger.warning(
66+
"Attempted path traversal attack blocked",
67+
identifier=identifier,
68+
processed_path=processed_path,
69+
project=active_project.name,
70+
)
71+
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
72+
73+
# Check migration status and wait briefly if needed
74+
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
75+
76+
migration_status = await wait_for_migration_or_return_status(
77+
timeout=5.0, project_name=active_project.name
78+
)
79+
if migration_status: # pragma: no cover
80+
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before reading notes."
81+
project_url = active_project.project_url
82+
4683
# Get the file via REST API - first try direct permalink lookup
4784
entity_path = memory_url_path(identifier)
48-
path = f"/resource/{entity_path}"
85+
path = f"{project_url}/resource/{entity_path}"
4986
logger.info(f"Attempting to read note from URL: {path}")
5087

5188
try:
@@ -62,14 +99,14 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
6299

63100
# Fallback 1: Try title search via API
64101
logger.info(f"Search title for: {identifier}")
65-
title_results = await search_notes(query=identifier, search_type="title")
102+
title_results = await search_notes.fn(query=identifier, search_type="title", project=project)
66103

67104
if title_results and title_results.results:
68105
result = title_results.results[0] # Get the first/best match
69106
if result.permalink:
70107
try:
71108
# Try to fetch the content using the found permalink
72-
path = f"/resource/{result.permalink}"
109+
path = f"{project_url}/resource/{result.permalink}"
73110
response = await call_get(
74111
client, path, params={"page": page, "page_size": page_size}
75112
)
@@ -86,7 +123,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
86123

87124
# Fallback 2: Text search as a last resort
88125
logger.info(f"Title search failed, trying text search for: {identifier}")
89-
text_results = await search_notes(query=identifier, search_type="text")
126+
text_results = await search_notes.fn(query=identifier, search_type="text", project=project)
90127

91128
# We didn't find a direct match, construct a helpful error message
92129
if not text_results or not text_results.results:

src/basic_memory/repository/search_repository.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,8 +527,8 @@ async def index_item(
527527
async with db.scoped_session(self.session_maker) as session:
528528
# Delete existing record if any
529529
await session.execute(
530-
text("DELETE FROM search_index WHERE permalink = :permalink"),
531-
{"permalink": search_index_row.permalink},
530+
text("DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"),
531+
{"permalink": search_index_row.permalink, "project_id": self.project_id},
532532
)
533533

534534
# Prepare data for insert with project_id

src/basic_memory/services/context_service.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,8 @@ async def find_related(
245245
# For compatibility with the old query, we still need this for filtering
246246
values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
247247

248-
# Parameters for bindings
249-
params = {"max_depth": max_depth, "max_results": max_results}
248+
# Parameters for bindings - include project_id for security filtering
249+
params = {"max_depth": max_depth, "max_results": max_results, "project_id": self.search_repository.project_id}
250250

251251
# Build date and timeframe filters conditionally based on since parameter
252252
if since:
@@ -258,6 +258,10 @@ async def find_related(
258258
date_filter = ""
259259
relation_date_filter = ""
260260
timeframe_condition = ""
261+
262+
# Add project filtering for security - ensure all entities and relations belong to the same project
263+
project_filter = "AND e.project_id = :project_id"
264+
relation_project_filter = "AND e_from.project_id = :project_id"
261265

262266
# Use a CTE that operates directly on entity and relation tables
263267
# This avoids the overhead of the search_index virtual table
@@ -284,6 +288,7 @@ async def find_related(
284288
FROM entity e
285289
WHERE e.id IN ({entity_id_values})
286290
{date_filter}
291+
{project_filter}
287292
288293
UNION ALL
289294
@@ -314,8 +319,12 @@ async def find_related(
314319
JOIN entity e_from ON (
315320
r.from_id = e_from.id
316321
{relation_date_filter}
322+
{relation_project_filter}
317323
)
324+
LEFT JOIN entity e_to ON (r.to_id = e_to.id)
318325
WHERE eg.depth < :max_depth
326+
-- Ensure to_entity (if exists) also belongs to same project
327+
AND (r.to_id IS NULL OR e_to.project_id = :project_id)
319328
320329
UNION ALL
321330
@@ -348,6 +357,7 @@ async def find_related(
348357
ELSE eg.from_id
349358
END
350359
{date_filter}
360+
{project_filter}
351361
)
352362
WHERE eg.depth < :max_depth
353363
-- Only include entities connected by relations within timeframe if specified

src/basic_memory/sync/watch_service.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,36 @@ async def handle_changes(self, project: Project, changes: Set[FileChange]) -> No
284284
# Process deletes
285285
for path in deletes:
286286
if path not in processed:
287-
logger.debug("Processing deleted file", path=path)
288-
await sync_service.handle_delete(path)
289-
self.state.add_event(path=path, action="deleted", status="success")
290-
self.console.print(f"[red]✕[/red] {path}")
291-
logger.info(f"deleted: {path}")
292-
processed.add(path)
293-
delete_count += 1
287+
# Check if file still exists on disk (vim atomic write edge case)
288+
full_path = directory / path
289+
if full_path.exists() and full_path.is_file():
290+
# File still exists despite DELETE event - treat as modification
291+
logger.debug("File exists despite DELETE event, treating as modification", path=path)
292+
entity, checksum = await sync_service.sync_file(path, new=False)
293+
self.state.add_event(path=path, action="modified", status="success", checksum=checksum)
294+
self.console.print(f"[yellow]✎[/yellow] {path} (atomic write)")
295+
logger.info(f"atomic write detected: {path}")
296+
processed.add(path)
297+
modify_count += 1
298+
else:
299+
# Check if this was a directory - skip if so
300+
# (we can't tell if the deleted path was a directory since it no longer exists,
301+
# so we check if there's an entity in the database for it)
302+
entity = await sync_service.entity_repository.get_by_file_path(path)
303+
if entity is None:
304+
# No entity means this was likely a directory - skip it
305+
logger.debug(f"Skipping deleted path with no entity (likely directory), path={path}")
306+
processed.add(path)
307+
continue
308+
309+
# File truly deleted
310+
logger.debug("Processing deleted file", path=path)
311+
await sync_service.handle_delete(path)
312+
self.state.add_event(path=path, action="deleted", status="success")
313+
self.console.print(f"[red]✕[/red] {path}")
314+
logger.info(f"deleted: {path}")
315+
processed.add(path)
316+
delete_count += 1
294317

295318
# Process adds
296319
for path in adds:

0 commit comments

Comments
 (0)