Skip to content

Commit db578cc

Browse files
authored
fix(mcp): recover edit_note when file exists on disk but is not indexed (#934)
Closes #581 Signed-off-by: phernandez <paul@basicmemory.com>
1 parent df485aa commit db578cc

10 files changed

Lines changed: 1200 additions & 9 deletions

File tree

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

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,18 @@
1010
- Simplified caching strategies
1111
"""
1212

13+
import os
14+
import pathlib
15+
1316
from fastapi import APIRouter, HTTPException, Response, Path
1417
from loguru import logger
1518

1619
import logfire
20+
from basic_memory.ignore_utils import (
21+
IGNORED_PATH_REJECTION_DETAIL,
22+
load_gitignore_patterns,
23+
should_ignore_path,
24+
)
1725
from basic_memory.deps import (
1826
EntityServiceV2ExternalDep,
1927
SearchServiceV2ExternalDep,
@@ -24,6 +32,7 @@
2432
EntityRepositoryV2ExternalDep,
2533
RelationRepositoryV2ExternalDep,
2634
ProjectExternalIdPathDep,
35+
SyncServiceV2ExternalDep,
2736
TaskSchedulerDep,
2837
)
2938
from basic_memory.schemas import DeleteEntitiesResponse
@@ -40,8 +49,10 @@
4049
MoveDirectoryRequestV2,
4150
DeleteDirectoryRequestV2,
4251
OrphanEntitiesResponse,
52+
SyncFileRequest,
4353
)
4454
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
55+
from basic_memory.utils import validate_project_path
4556

4657
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
4758

@@ -236,6 +247,187 @@ async def resolve_identifier(
236247
return result
237248

238249

250+
## Single-file sync endpoint
251+
252+
253+
def _canonical_file_path(home: pathlib.Path, segments: list[str]) -> str | None:
254+
"""Resolve the actual on-disk casing of a file path under the project home.
255+
256+
Trigger: case-insensitive filesystems (macOS/Windows) pass existence checks for
257+
wrong-cased paths like 'notes/Disk-Note.md' when the file is 'notes/disk-note.md'.
258+
Why: indexing the caller-supplied casing misses the existing DB row keyed by the
259+
on-disk path and inserts a duplicate entity under the wrong-cased path.
260+
Outcome: each segment is matched against real directory entries — exact name first
261+
(so distinct case-variant files on case-sensitive filesystems stay distinct),
262+
then a unique case-insensitive match. Returns None when any segment cannot be
263+
matched to exactly one entry, including missing files. Traversal stops at the
264+
project boundary: a directory whose resolved path escapes the project home is
265+
never scanned.
266+
"""
267+
resolved_home = home.resolve()
268+
current = home
269+
canonical_segments: list[str] = []
270+
for segment in segments:
271+
# Trigger: a previously matched segment may be a symlink whose target lies
272+
# outside the project root (e.g. wrong-cased 'LINK' matched the on-disk
273+
# 'link' -> /tmp/outside on a case-sensitive filesystem).
274+
# Why: os.scandir follows symlinked directories, so continuing would read
275+
# directory contents outside the project boundary even though the
276+
# post-canonicalization containment check rejects the request later.
277+
# Outcome: bail before scanning the moment resolution escapes the home.
278+
if not current.resolve().is_relative_to(resolved_home):
279+
return None
280+
try:
281+
with os.scandir(current) as entries_iter:
282+
entries = [entry.name for entry in entries_iter]
283+
except OSError:
284+
# A parent segment resolved to a non-directory (or vanished): no canonical
285+
# path exists for the remaining segments.
286+
return None
287+
if segment in entries:
288+
matched = segment
289+
else:
290+
matches = [entry for entry in entries if entry.lower() == segment.lower()]
291+
if len(matches) != 1:
292+
return None
293+
matched = matches[0]
294+
canonical_segments.append(matched)
295+
current = current / matched
296+
return "/".join(canonical_segments)
297+
298+
299+
@router.post("/sync-file", response_model=EntityResponseV2)
300+
async def sync_file(
301+
data: SyncFileRequest,
302+
project_id: ProjectExternalIdPathDep,
303+
sync_service: SyncServiceV2ExternalDep,
304+
project_config: ProjectConfigV2ExternalDep,
305+
search_service: SearchServiceV2ExternalDep,
306+
app_config: AppConfigDep,
307+
) -> EntityResponseV2:
308+
"""Index a single markdown file that exists on disk but is not indexed yet.
309+
310+
Recovery path for files written directly to disk before the watcher indexed
311+
them (#581): callers such as edit_note can index the exact file and retry
312+
identifier resolution without running a full project sync.
313+
314+
Args:
315+
data: Request containing the markdown file path relative to project root
316+
317+
Returns:
318+
The indexed entity
319+
320+
Raises:
321+
HTTPException: 400 if the path escapes the project root, contains
322+
non-normalized segments, matches the project ignore rules, or is
323+
not markdown, 404 if the file does not exist on disk
324+
"""
325+
with logfire.span(
326+
"api.request.knowledge.sync_file",
327+
entrypoint="api",
328+
domain="knowledge",
329+
action="sync_file",
330+
):
331+
logger.info(f"API v2 request: sync_file file_path='{data.file_path}'")
332+
333+
if not validate_project_path(data.file_path, project_config.home):
334+
raise HTTPException(
335+
status_code=400,
336+
detail=f"File path '{data.file_path}' is not allowed - "
337+
"paths must stay within project boundaries",
338+
)
339+
340+
# Trigger: segments like './' or '//' survive the traversal check above
341+
# Why: a non-normalized path would index under a non-canonical DB key
342+
# Outcome: reject fail-fast instead of guessing the canonical form
343+
segments = data.file_path.replace("\\", "/").split("/")
344+
if any(segment in ("", ".") for segment in segments):
345+
raise HTTPException(
346+
status_code=400,
347+
detail=f"File path '{data.file_path}' is not normalized - "
348+
"segments like './' or '//' are not allowed",
349+
)
350+
351+
# Canonicalize to the actual on-disk casing so the DB lookup below hits the
352+
# row keyed by the real path instead of inserting a wrong-cased duplicate.
353+
file_path = _canonical_file_path(project_config.home, segments)
354+
if file_path is None:
355+
raise HTTPException(
356+
status_code=404, detail=f"File not found on disk: '{data.file_path}'"
357+
)
358+
# Trigger: canonicalization rewrote a segment to its on-disk form, and that
359+
# segment may be a symlink. The pre-check above validated the ORIGINAL
360+
# request path — on a case-sensitive filesystem 'LINK/secret.md' does not
361+
# exist, so resolve() cannot follow the real 'link' symlink and the check
362+
# passes even when 'link' points outside the project root.
363+
# Why: indexing through an escaping symlink would read and index content
364+
# outside the project boundary — and even an is_file() existence probe on
365+
# the joined path would follow the symlink and stat its target, so
366+
# containment must hold BEFORE any filesystem probe that follows symlinks.
367+
# Path.resolve() only walks symlink names (readlink); it never opens or
368+
# stats the final target, so it is safe to run pre-containment.
369+
# Outcome: the canonical path is re-validated and the fully-resolved absolute
370+
# target must stay inside the resolved project home; escapes get a 400
371+
# before the file-existence probe below ever touches the target.
372+
resolved_target = (project_config.home / file_path).resolve()
373+
if not validate_project_path(file_path, project_config.home) or not (
374+
resolved_target.is_relative_to(project_config.home.resolve())
375+
):
376+
raise HTTPException(
377+
status_code=400,
378+
detail=f"File path '{data.file_path}' is not allowed - "
379+
"paths must stay within project boundaries",
380+
)
381+
# Containment holds, so probing the resolved target cannot leave the project.
382+
if not resolved_target.is_file():
383+
raise HTTPException(
384+
status_code=404, detail=f"File not found on disk: '{data.file_path}'"
385+
)
386+
# Trigger: the canonical path matches the .bmignore / project .gitignore rules
387+
# Why: scan and watch flows filter ignored files before they ever reach the
388+
# indexer; indexing one here would bypass the ignored-file contract and
389+
# make hidden or gitignored content searchable
390+
# Outcome: the same should_ignore_path() rules apply to single-file sync
391+
ignore_patterns = load_gitignore_patterns(project_config.home)
392+
if should_ignore_path(
393+
project_config.home / file_path, project_config.home, ignore_patterns
394+
):
395+
raise HTTPException(
396+
status_code=400,
397+
detail=f"File path '{data.file_path}' {IGNORED_PATH_REJECTION_DETAIL} "
398+
"and cannot be indexed",
399+
)
400+
if not sync_service.file_service.is_markdown(file_path):
401+
raise HTTPException(
402+
status_code=400,
403+
detail=f"Only markdown files can be indexed: '{data.file_path}'",
404+
)
405+
406+
# Trigger: the file may already have a DB row (e.g. modified on disk after indexing)
407+
# Why: the indexer needs to know whether to insert or update the entity
408+
# Outcome: new is computed from the database instead of assumed by the caller
409+
existing = await sync_service.entity_repository.get_by_file_path(file_path)
410+
synced = await sync_service.sync_one_markdown_file(
411+
file_path, new=existing is None, index_search=True
412+
)
413+
414+
# Trigger: semantic search is enabled and the entity index was just refreshed
415+
# Why: the project sync flow awaits sync_entity_vectors_batch() inline after
416+
# indexing changed files (SyncService.sync); without the single-entity
417+
# equivalent, a note recovered via sync-file stays missing or stale in
418+
# semantic search until a later edit or full project sync
419+
# Outcome: vectors refresh synchronously before the response returns,
420+
# mirroring the sync flow instead of the out-of-band scheduler
421+
if app_config.semantic_search_enabled:
422+
await search_service.sync_entity_vectors_batch([synced.entity.id])
423+
424+
result = EntityResponseV2.model_validate(synced.entity)
425+
logger.info(
426+
f"API v2 response: sync_file file_path='{file_path}' external_id={result.external_id}"
427+
)
428+
return result
429+
430+
239431
## Read endpoints
240432

241433

src/basic_memory/ignore_utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77
from basic_memory.config import resolve_data_dir
88

99

10+
# Marker shared by the API ignored-path rejection detail and MCP-side error handling.
11+
# The sync-file endpoint embeds it in its 400 detail and edit_note's disk recovery
12+
# matches on it, so "exists but ignored" stays distinguishable from generic rejections
13+
# without duplicating message text across layers.
14+
IGNORED_PATH_REJECTION_DETAIL = (
15+
"matches Basic Memory ignore rules (.bmignore or project .gitignore)"
16+
)
17+
18+
1019
# Common directories and patterns to ignore by default
1120
# These are used as fallback if .bmignore doesn't exist
1221
DEFAULT_IGNORE_PATTERNS = {

src/basic_memory/mcp/clients/knowledge.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,35 @@ async def delete_directory(self, directory: str) -> DirectoryDeleteResult:
276276
)
277277
return DirectoryDeleteResult.model_validate(response.json())
278278

279+
# --- Single-file sync ---
280+
281+
async def sync_file(self, file_path: str) -> EntityResponse:
282+
"""Index a markdown file that exists on disk but is not indexed yet.
283+
284+
Args:
285+
file_path: Markdown file path relative to the project root
286+
287+
Returns:
288+
EntityResponse for the indexed entity
289+
290+
Raises:
291+
ToolError: If the file does not exist on disk or indexing fails
292+
"""
293+
with logfire.span(
294+
"mcp.client.knowledge.sync_file",
295+
client_name="knowledge",
296+
operation="sync_file",
297+
):
298+
response = await call_post(
299+
self.http_client,
300+
f"{self._base_path}/sync-file",
301+
json={"file_path": file_path},
302+
client_name="knowledge",
303+
operation="sync_file",
304+
path_template="/v2/projects/{project_id}/knowledge/sync-file",
305+
)
306+
return EntityResponse.model_validate(response.json())
307+
279308
# --- Orphan detection ---
280309

281310
async def get_orphans(self) -> list[GraphNode]:

0 commit comments

Comments
 (0)